@agentdeck/bridge 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (380) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/claude-code.d.ts +11 -27
  3. package/dist/adapters/claude-code.d.ts.map +1 -1
  4. package/dist/adapters/claude-code.js +29 -123
  5. package/dist/adapters/claude-code.js.map +1 -1
  6. package/dist/adapters/codex-cli.d.ts +21 -0
  7. package/dist/adapters/codex-cli.d.ts.map +1 -0
  8. package/dist/adapters/codex-cli.js +53 -0
  9. package/dist/adapters/codex-cli.js.map +1 -0
  10. package/dist/adapters/index.d.ts +4 -0
  11. package/dist/adapters/index.d.ts.map +1 -1
  12. package/dist/adapters/index.js +13 -0
  13. package/dist/adapters/index.js.map +1 -1
  14. package/dist/adapters/monitor.d.ts +34 -0
  15. package/dist/adapters/monitor.d.ts.map +1 -0
  16. package/dist/adapters/monitor.js +86 -0
  17. package/dist/adapters/monitor.js.map +1 -0
  18. package/dist/adapters/openclaw.d.ts +36 -2
  19. package/dist/adapters/openclaw.d.ts.map +1 -1
  20. package/dist/adapters/openclaw.js +329 -59
  21. package/dist/adapters/openclaw.js.map +1 -1
  22. package/dist/adapters/opencode-adapter.d.ts +48 -0
  23. package/dist/adapters/opencode-adapter.d.ts.map +1 -0
  24. package/dist/adapters/opencode-adapter.js +345 -0
  25. package/dist/adapters/opencode-adapter.js.map +1 -0
  26. package/dist/adapters/pty-adapter.d.ts +56 -0
  27. package/dist/adapters/pty-adapter.d.ts.map +1 -0
  28. package/dist/adapters/pty-adapter.js +143 -0
  29. package/dist/adapters/pty-adapter.js.map +1 -0
  30. package/dist/adb-reverse.d.ts +25 -0
  31. package/dist/adb-reverse.d.ts.map +1 -0
  32. package/dist/adb-reverse.js +129 -0
  33. package/dist/adb-reverse.js.map +1 -0
  34. package/dist/antigravity-local.d.ts +7 -0
  35. package/dist/antigravity-local.d.ts.map +1 -0
  36. package/dist/antigravity-local.js +153 -0
  37. package/dist/antigravity-local.js.map +1 -0
  38. package/dist/apme/adapters/claude-hook.d.ts +15 -0
  39. package/dist/apme/adapters/claude-hook.d.ts.map +1 -0
  40. package/dist/apme/adapters/claude-hook.js +85 -0
  41. package/dist/apme/adapters/claude-hook.js.map +1 -0
  42. package/dist/apme/adapters/claude-pty.d.ts +28 -0
  43. package/dist/apme/adapters/claude-pty.d.ts.map +1 -0
  44. package/dist/apme/adapters/claude-pty.js +88 -0
  45. package/dist/apme/adapters/claude-pty.js.map +1 -0
  46. package/dist/apme/adapters/timeline.d.ts +16 -0
  47. package/dist/apme/adapters/timeline.d.ts.map +1 -0
  48. package/dist/apme/adapters/timeline.js +66 -0
  49. package/dist/apme/adapters/timeline.js.map +1 -0
  50. package/dist/apme/classifier.d.ts +53 -0
  51. package/dist/apme/classifier.d.ts.map +1 -0
  52. package/dist/apme/classifier.js +266 -0
  53. package/dist/apme/classifier.js.map +1 -0
  54. package/dist/apme/claude-transcript-reader.d.ts +38 -0
  55. package/dist/apme/claude-transcript-reader.d.ts.map +1 -0
  56. package/dist/apme/claude-transcript-reader.js +127 -0
  57. package/dist/apme/claude-transcript-reader.js.map +1 -0
  58. package/dist/apme/collector.d.ts +108 -0
  59. package/dist/apme/collector.d.ts.map +1 -0
  60. package/dist/apme/collector.js +659 -0
  61. package/dist/apme/collector.js.map +1 -0
  62. package/dist/apme/dashboard-html.d.ts +9 -0
  63. package/dist/apme/dashboard-html.d.ts.map +1 -0
  64. package/dist/apme/dashboard-html.js +416 -0
  65. package/dist/apme/dashboard-html.js.map +1 -0
  66. package/dist/apme/http.d.ts +19 -0
  67. package/dist/apme/http.d.ts.map +1 -0
  68. package/dist/apme/http.js +210 -0
  69. package/dist/apme/http.js.map +1 -0
  70. package/dist/apme/hw-sampler.d.ts +30 -0
  71. package/dist/apme/hw-sampler.d.ts.map +1 -0
  72. package/dist/apme/hw-sampler.js +106 -0
  73. package/dist/apme/hw-sampler.js.map +1 -0
  74. package/dist/apme/index.d.ts +43 -0
  75. package/dist/apme/index.d.ts.map +1 -0
  76. package/dist/apme/index.js +75 -0
  77. package/dist/apme/index.js.map +1 -0
  78. package/dist/apme/outcome.d.ts +48 -0
  79. package/dist/apme/outcome.d.ts.map +1 -0
  80. package/dist/apme/outcome.js +232 -0
  81. package/dist/apme/outcome.js.map +1 -0
  82. package/dist/apme/recommend.d.ts +32 -0
  83. package/dist/apme/recommend.d.ts.map +1 -0
  84. package/dist/apme/recommend.js +41 -0
  85. package/dist/apme/recommend.js.map +1 -0
  86. package/dist/apme/runner.d.ts +94 -0
  87. package/dist/apme/runner.d.ts.map +1 -0
  88. package/dist/apme/runner.js +812 -0
  89. package/dist/apme/runner.js.map +1 -0
  90. package/dist/apme/settings.d.ts +65 -0
  91. package/dist/apme/settings.d.ts.map +1 -0
  92. package/dist/apme/settings.js +97 -0
  93. package/dist/apme/settings.js.map +1 -0
  94. package/dist/apme/store.d.ts +99 -0
  95. package/dist/apme/store.d.ts.map +1 -0
  96. package/dist/apme/store.js +911 -0
  97. package/dist/apme/store.js.map +1 -0
  98. package/dist/apme/tuner.d.ts +64 -0
  99. package/dist/apme/tuner.d.ts.map +1 -0
  100. package/dist/apme/tuner.js +297 -0
  101. package/dist/apme/tuner.js.map +1 -0
  102. package/dist/apme/types.d.ts +11 -0
  103. package/dist/apme/types.d.ts.map +1 -0
  104. package/dist/apme/types.js +10 -0
  105. package/dist/apme/types.js.map +1 -0
  106. package/dist/auth.d.ts +1 -1
  107. package/dist/auth.d.ts.map +1 -1
  108. package/dist/auth.js +15 -5
  109. package/dist/auth.js.map +1 -1
  110. package/dist/bridge-core.d.ts +162 -0
  111. package/dist/bridge-core.d.ts.map +1 -0
  112. package/dist/bridge-core.js +627 -0
  113. package/dist/bridge-core.js.map +1 -0
  114. package/dist/check-deps.d.ts +3 -1
  115. package/dist/check-deps.d.ts.map +1 -1
  116. package/dist/check-deps.js +34 -13
  117. package/dist/check-deps.js.map +1 -1
  118. package/dist/cli.d.ts +3 -0
  119. package/dist/cli.d.ts.map +1 -0
  120. package/dist/cli.js +1235 -0
  121. package/dist/cli.js.map +1 -0
  122. package/dist/codex-auth.d.ts +10 -0
  123. package/dist/codex-auth.d.ts.map +1 -0
  124. package/dist/codex-auth.js +63 -0
  125. package/dist/codex-auth.js.map +1 -0
  126. package/dist/codex-output-parser.d.ts +21 -0
  127. package/dist/codex-output-parser.d.ts.map +1 -0
  128. package/dist/codex-output-parser.js +253 -0
  129. package/dist/codex-output-parser.js.map +1 -0
  130. package/dist/d200h/hid-protocol.d.ts +62 -0
  131. package/dist/d200h/hid-protocol.d.ts.map +1 -0
  132. package/dist/d200h/hid-protocol.js +124 -0
  133. package/dist/d200h/hid-protocol.js.map +1 -0
  134. package/dist/d200h/image-renderer.d.ts +36 -0
  135. package/dist/d200h/image-renderer.d.ts.map +1 -0
  136. package/dist/d200h/image-renderer.js +487 -0
  137. package/dist/d200h/image-renderer.js.map +1 -0
  138. package/dist/daemon-server.d.ts +19 -0
  139. package/dist/daemon-server.d.ts.map +1 -0
  140. package/dist/daemon-server.js +1168 -0
  141. package/dist/daemon-server.js.map +1 -0
  142. package/dist/daemon-ws-client.d.ts +82 -0
  143. package/dist/daemon-ws-client.d.ts.map +1 -0
  144. package/dist/daemon-ws-client.js +179 -0
  145. package/dist/daemon-ws-client.js.map +1 -0
  146. package/dist/daemon.d.ts +10 -0
  147. package/dist/daemon.d.ts.map +1 -0
  148. package/dist/daemon.js +56 -0
  149. package/dist/daemon.js.map +1 -0
  150. package/dist/display-monitor.d.ts +25 -0
  151. package/dist/display-monitor.d.ts.map +1 -0
  152. package/dist/display-monitor.js +143 -0
  153. package/dist/display-monitor.js.map +1 -0
  154. package/dist/esp32-serial.d.ts +112 -0
  155. package/dist/esp32-serial.d.ts.map +1 -0
  156. package/dist/esp32-serial.js +502 -0
  157. package/dist/esp32-serial.js.map +1 -0
  158. package/dist/gateway-probe.d.ts +12 -0
  159. package/dist/gateway-probe.d.ts.map +1 -0
  160. package/dist/gateway-probe.js +39 -0
  161. package/dist/gateway-probe.js.map +1 -0
  162. package/dist/hook-server.d.ts +29 -0
  163. package/dist/hook-server.d.ts.map +1 -1
  164. package/dist/hook-server.js +366 -8
  165. package/dist/hook-server.js.map +1 -1
  166. package/dist/index.d.ts +20 -2
  167. package/dist/index.d.ts.map +1 -1
  168. package/dist/index.js +1305 -562
  169. package/dist/index.js.map +1 -1
  170. package/dist/log-stream.d.ts +26 -0
  171. package/dist/log-stream.d.ts.map +1 -0
  172. package/dist/log-stream.js +111 -0
  173. package/dist/log-stream.js.map +1 -0
  174. package/dist/logger.d.ts +9 -2
  175. package/dist/logger.d.ts.map +1 -1
  176. package/dist/logger.js +36 -5
  177. package/dist/logger.js.map +1 -1
  178. package/dist/mdns.d.ts +15 -1
  179. package/dist/mdns.d.ts.map +1 -1
  180. package/dist/mdns.js +143 -28
  181. package/dist/mdns.js.map +1 -1
  182. package/dist/mlx-probe.d.ts +14 -0
  183. package/dist/mlx-probe.d.ts.map +1 -0
  184. package/dist/mlx-probe.js +44 -0
  185. package/dist/mlx-probe.js.map +1 -0
  186. package/dist/model-catalog.d.ts +5 -3
  187. package/dist/model-catalog.d.ts.map +1 -1
  188. package/dist/model-catalog.js +45 -29
  189. package/dist/model-catalog.js.map +1 -1
  190. package/dist/modules/adb-module.d.ts +14 -0
  191. package/dist/modules/adb-module.d.ts.map +1 -0
  192. package/dist/modules/adb-module.js +36 -0
  193. package/dist/modules/adb-module.js.map +1 -0
  194. package/dist/modules/d200h-module.d.ts +43 -0
  195. package/dist/modules/d200h-module.d.ts.map +1 -0
  196. package/dist/modules/d200h-module.js +360 -0
  197. package/dist/modules/d200h-module.js.map +1 -0
  198. package/dist/modules/d200h-renderer.d.ts +7 -0
  199. package/dist/modules/d200h-renderer.d.ts.map +1 -0
  200. package/dist/modules/d200h-renderer.js +321 -0
  201. package/dist/modules/d200h-renderer.js.map +1 -0
  202. package/dist/modules/index.d.ts +23 -0
  203. package/dist/modules/index.d.ts.map +1 -0
  204. package/dist/modules/index.js +65 -0
  205. package/dist/modules/index.js.map +1 -0
  206. package/dist/modules/mdns-module.d.ts +12 -0
  207. package/dist/modules/mdns-module.d.ts.map +1 -0
  208. package/dist/modules/mdns-module.js +21 -0
  209. package/dist/modules/mdns-module.js.map +1 -0
  210. package/dist/modules/pixoo-module.d.ts +8 -0
  211. package/dist/modules/pixoo-module.d.ts.map +1 -0
  212. package/dist/modules/pixoo-module.js +24 -0
  213. package/dist/modules/pixoo-module.js.map +1 -0
  214. package/dist/modules/serial-module.d.ts +18 -0
  215. package/dist/modules/serial-module.d.ts.map +1 -0
  216. package/dist/modules/serial-module.js +47 -0
  217. package/dist/modules/serial-module.js.map +1 -0
  218. package/dist/modules/types.d.ts +39 -0
  219. package/dist/modules/types.d.ts.map +1 -0
  220. package/dist/modules/types.js +2 -0
  221. package/dist/modules/types.js.map +1 -0
  222. package/dist/ollama-probe.d.ts +13 -0
  223. package/dist/ollama-probe.d.ts.map +1 -0
  224. package/dist/ollama-probe.js +46 -0
  225. package/dist/ollama-probe.js.map +1 -0
  226. package/dist/opencode-client.d.ts +126 -0
  227. package/dist/opencode-client.d.ts.map +1 -0
  228. package/dist/opencode-client.js +181 -0
  229. package/dist/opencode-client.js.map +1 -0
  230. package/dist/output-parser.d.ts +3 -0
  231. package/dist/output-parser.d.ts.map +1 -1
  232. package/dist/output-parser.js +29 -3
  233. package/dist/output-parser.js.map +1 -1
  234. package/dist/passive-observer.d.ts +38 -0
  235. package/dist/passive-observer.d.ts.map +1 -0
  236. package/dist/passive-observer.js +632 -0
  237. package/dist/passive-observer.js.map +1 -0
  238. package/dist/pixoo/pixoo-bridge.d.ts +50 -0
  239. package/dist/pixoo/pixoo-bridge.d.ts.map +1 -0
  240. package/dist/pixoo/pixoo-bridge.js +280 -0
  241. package/dist/pixoo/pixoo-bridge.js.map +1 -0
  242. package/dist/pixoo/pixoo-camera.d.ts +80 -0
  243. package/dist/pixoo/pixoo-camera.d.ts.map +1 -0
  244. package/dist/pixoo/pixoo-camera.js +271 -0
  245. package/dist/pixoo/pixoo-camera.js.map +1 -0
  246. package/dist/pixoo/pixoo-client.d.ts +67 -0
  247. package/dist/pixoo/pixoo-client.d.ts.map +1 -0
  248. package/dist/pixoo/pixoo-client.js +335 -0
  249. package/dist/pixoo/pixoo-client.js.map +1 -0
  250. package/dist/pixoo/pixoo-font.d.ts +24 -0
  251. package/dist/pixoo/pixoo-font.d.ts.map +1 -0
  252. package/dist/pixoo/pixoo-font.js +121 -0
  253. package/dist/pixoo/pixoo-font.js.map +1 -0
  254. package/dist/pixoo/pixoo-renderer.d.ts +32 -0
  255. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -0
  256. package/dist/pixoo/pixoo-renderer.js +696 -0
  257. package/dist/pixoo/pixoo-renderer.js.map +1 -0
  258. package/dist/pixoo/pixoo-settings.d.ts +10 -0
  259. package/dist/pixoo/pixoo-settings.d.ts.map +1 -0
  260. package/dist/pixoo/pixoo-settings.js +47 -0
  261. package/dist/pixoo/pixoo-settings.js.map +1 -0
  262. package/dist/pixoo/pixoo-sprites.d.ts +187 -0
  263. package/dist/pixoo/pixoo-sprites.d.ts.map +1 -0
  264. package/dist/pixoo/pixoo-sprites.js +926 -0
  265. package/dist/pixoo/pixoo-sprites.js.map +1 -0
  266. package/dist/pty-manager.d.ts +1 -1
  267. package/dist/pty-manager.d.ts.map +1 -1
  268. package/dist/pty-manager.js +36 -15
  269. package/dist/pty-manager.js.map +1 -1
  270. package/dist/session-aggregator.d.ts +41 -0
  271. package/dist/session-aggregator.d.ts.map +1 -0
  272. package/dist/session-aggregator.js +76 -0
  273. package/dist/session-aggregator.js.map +1 -0
  274. package/dist/session-focus-relay.d.ts +35 -0
  275. package/dist/session-focus-relay.d.ts.map +1 -0
  276. package/dist/session-focus-relay.js +127 -0
  277. package/dist/session-focus-relay.js.map +1 -0
  278. package/dist/session-registry.d.ts +88 -1
  279. package/dist/session-registry.d.ts.map +1 -1
  280. package/dist/session-registry.js +222 -15
  281. package/dist/session-registry.js.map +1 -1
  282. package/dist/session-timeline-relay.d.ts +27 -0
  283. package/dist/session-timeline-relay.d.ts.map +1 -0
  284. package/dist/session-timeline-relay.js +124 -0
  285. package/dist/session-timeline-relay.js.map +1 -0
  286. package/dist/state-machine.d.ts +1 -0
  287. package/dist/state-machine.d.ts.map +1 -1
  288. package/dist/state-machine.js +14 -0
  289. package/dist/state-machine.js.map +1 -1
  290. package/dist/terminal-status.d.ts +28 -0
  291. package/dist/terminal-status.d.ts.map +1 -0
  292. package/dist/terminal-status.js +152 -0
  293. package/dist/terminal-status.js.map +1 -0
  294. package/dist/timeline-store.d.ts +22 -0
  295. package/dist/timeline-store.d.ts.map +1 -0
  296. package/dist/timeline-store.js +79 -0
  297. package/dist/timeline-store.js.map +1 -0
  298. package/dist/timeline-summarizer.d.ts +14 -0
  299. package/dist/timeline-summarizer.d.ts.map +1 -0
  300. package/dist/timeline-summarizer.js +154 -0
  301. package/dist/timeline-summarizer.js.map +1 -0
  302. package/dist/tts.d.ts +25 -0
  303. package/dist/tts.d.ts.map +1 -0
  304. package/dist/tts.js +99 -0
  305. package/dist/tts.js.map +1 -0
  306. package/dist/tui/ansi.d.ts +92 -0
  307. package/dist/tui/ansi.d.ts.map +1 -0
  308. package/dist/tui/ansi.js +181 -0
  309. package/dist/tui/ansi.js.map +1 -0
  310. package/dist/tui/dashboard.d.ts +35 -0
  311. package/dist/tui/dashboard.d.ts.map +1 -0
  312. package/dist/tui/dashboard.js +436 -0
  313. package/dist/tui/dashboard.js.map +1 -0
  314. package/dist/tui/gauge.d.ts +15 -0
  315. package/dist/tui/gauge.d.ts.map +1 -0
  316. package/dist/tui/gauge.js +82 -0
  317. package/dist/tui/gauge.js.map +1 -0
  318. package/dist/tui/renderer.d.ts +15 -0
  319. package/dist/tui/renderer.d.ts.map +1 -0
  320. package/dist/tui/renderer.js +758 -0
  321. package/dist/tui/renderer.js.map +1 -0
  322. package/dist/tui/screen.d.ts +27 -0
  323. package/dist/tui/screen.d.ts.map +1 -0
  324. package/dist/tui/screen.js +109 -0
  325. package/dist/tui/screen.js.map +1 -0
  326. package/dist/tui/terrarium.d.ts +96 -0
  327. package/dist/tui/terrarium.d.ts.map +1 -0
  328. package/dist/tui/terrarium.js +912 -0
  329. package/dist/tui/terrarium.js.map +1 -0
  330. package/dist/types.d.ts +4 -2
  331. package/dist/types.d.ts.map +1 -1
  332. package/dist/types.js +3 -2
  333. package/dist/types.js.map +1 -1
  334. package/dist/usage-api.d.ts +9 -2
  335. package/dist/usage-api.d.ts.map +1 -1
  336. package/dist/usage-api.js +224 -42
  337. package/dist/usage-api.js.map +1 -1
  338. package/dist/usage-event.d.ts +12 -0
  339. package/dist/usage-event.d.ts.map +1 -0
  340. package/dist/usage-event.js +78 -0
  341. package/dist/usage-event.js.map +1 -0
  342. package/dist/usage-tracker.d.ts +4 -0
  343. package/dist/usage-tracker.d.ts.map +1 -1
  344. package/dist/usage-tracker.js +17 -0
  345. package/dist/usage-tracker.js.map +1 -1
  346. package/dist/utility-proxy.d.ts +30 -0
  347. package/dist/utility-proxy.d.ts.map +1 -0
  348. package/dist/utility-proxy.js +217 -0
  349. package/dist/utility-proxy.js.map +1 -0
  350. package/dist/utils/project-name.d.ts +22 -0
  351. package/dist/utils/project-name.d.ts.map +1 -0
  352. package/dist/utils/project-name.js +72 -0
  353. package/dist/utils/project-name.js.map +1 -0
  354. package/dist/version-check.d.ts +26 -0
  355. package/dist/version-check.d.ts.map +1 -0
  356. package/dist/version-check.js +225 -0
  357. package/dist/version-check.js.map +1 -0
  358. package/dist/voice-assistant.d.ts +63 -0
  359. package/dist/voice-assistant.d.ts.map +1 -0
  360. package/dist/voice-assistant.js +406 -0
  361. package/dist/voice-assistant.js.map +1 -0
  362. package/dist/voice.d.ts +2 -0
  363. package/dist/voice.d.ts.map +1 -1
  364. package/dist/voice.js +26 -1
  365. package/dist/voice.js.map +1 -1
  366. package/dist/wake-word.d.ts +35 -0
  367. package/dist/wake-word.d.ts.map +1 -0
  368. package/dist/wake-word.js +225 -0
  369. package/dist/wake-word.js.map +1 -0
  370. package/dist/whisper-server-manager.d.ts +1 -1
  371. package/dist/whisper-server-manager.js +1 -1
  372. package/dist/wifi-config.d.ts +24 -0
  373. package/dist/wifi-config.d.ts.map +1 -0
  374. package/dist/wifi-config.js +84 -0
  375. package/dist/wifi-config.js.map +1 -0
  376. package/dist/ws-server.d.ts +12 -0
  377. package/dist/ws-server.d.ts.map +1 -1
  378. package/dist/ws-server.js +81 -3
  379. package/dist/ws-server.js.map +1 -1
  380. package/package.json +22 -13
package/dist/index.js CHANGED
@@ -1,30 +1,48 @@
1
- #!/usr/bin/env node
2
- import { Command } from 'commander';
3
- import { UsageTracker } from './usage-tracker.js';
4
- import { StateMachine } from './state-machine.js';
5
- import { WsServer } from './ws-server.js';
1
+ /**
2
+ * AgentDeck Bridge session entry point.
3
+ *
4
+ * Exports `startSession()` called by cli.ts.
5
+ * Uses BridgeCore for shared infrastructure.
6
+ */
7
+ import { BridgeCore } from './bridge-core.js';
8
+ import { initApme } from './apme/index.js';
9
+ import { readLastTurn as readClaudeTranscriptLastTurn } from './apme/claude-transcript-reader.js';
10
+ import { claudeHookToSpans } from './apme/adapters/claude-hook.js';
11
+ import { claudePtyParserEventToSpans, claudePtyResponseToSpan } from './apme/adapters/claude-pty.js';
12
+ import { timelineEntryToSpans } from './apme/adapters/timeline.js';
13
+ import { spanNameForKind } from '@agentdeck/shared';
14
+ import { randomUUID } from 'crypto';
6
15
  import { VoiceManager } from './voice.js';
7
16
  import { checkDependencies } from './check-deps.js';
8
- import { enableDebugLog, debug } from './logger.js';
17
+ import { enableDebugLog, log, debug, setPtyMode } from './logger.js';
9
18
  import { EventJournal } from './event-journal.js';
10
19
  import { PtyRingBuffer } from './pty-ringbuffer.js';
11
20
  import { createDiagDump } from './diag-analyzer.js';
12
- import { createAdapter } from './adapters/index.js';
13
- import { ClaudeCodeAdapter } from './adapters/claude-code.js';
14
- import { BRIDGE_WS_PORT, State, } from './types.js';
15
- import { execSync } from 'child_process';
16
- import { readFileSync, writeFileSync, existsSync } from 'fs';
17
- import { resolve, dirname, join } from 'path';
21
+ import { createAdapter, ClaudeCodeAdapter, CodexCliAdapter, OpenCodeAdapter } from './adapters/index.js';
22
+ import { MonitorAdapter } from './adapters/monitor.js';
23
+ import { UtilityProxy } from './utility-proxy.js';
24
+ import { BridgeLogStream } from './log-stream.js';
25
+ import { extractTopicHint, summarizeResponse } from './timeline-summarizer.js';
26
+ import { cleanDetailText, cleanRawText } from '@agentdeck/shared';
27
+ import { VoiceAssistantManager } from './voice-assistant.js';
28
+ import { TerminalStatus } from './terminal-status.js';
29
+ import { readFileSync } from 'fs';
30
+ import { resolve, dirname } from 'path';
18
31
  import { fileURLToPath } from 'url';
19
- import { homedir } from 'os';
20
- import { randomUUID } from 'crypto';
21
- import { register as registerSession, deregister as deregisterSession, listActive as listActiveSessions, findAvailablePort, detectTmuxSession, } from './session-registry.js';
22
- import { fetchUsageFromApi } from './usage-api.js';
23
- import { advertiseBridge } from './mdns.js';
24
- import { getOrCreateToken, getWsUrl } from './auth.js';
32
+ import { execSync } from 'child_process';
33
+ import { listActive as listActiveSessions, findAvailablePort, detectTmuxSession, findDaemonPortAsync, } from './session-registry.js';
34
+ import { resolveProjectName } from './utils/project-name.js';
35
+ import { DaemonWsClient } from './daemon-ws-client.js';
36
+ import { getLanIp } from '@agentdeck/shared';
37
+ import { initModules, stopModules, createDefaultModules, } from './modules/index.js';
38
+ import { onESP32Message, sendWifiProvisionToAll, } from './esp32-serial.js';
39
+ import { loadWifiConfig } from './wifi-config.js';
40
+ import { getAdbDeviceCount } from './adb-reverse.js';
41
+ import { esp32ConnectionCount, getESP32Ports } from './esp32-serial.js';
42
+ import { getPixooDeviceDetails, getLastFrame, renderPreviewFrame } from './pixoo/pixoo-bridge.js';
43
+ import { BRIDGE_WS_PORT, State, } from './types.js';
25
44
  function loadTemplates() {
26
45
  try {
27
- // Try multiple locations: relative to bridge, project root, etc.
28
46
  const candidates = [
29
47
  resolve(dirname(fileURLToPath(import.meta.url)), '../../config/prompt-templates.json'),
30
48
  resolve(process.cwd(), 'config/prompt-templates.json'),
@@ -33,181 +51,76 @@ function loadTemplates() {
33
51
  try {
34
52
  const data = JSON.parse(readFileSync(p, 'utf-8'));
35
53
  if (Array.isArray(data?.templates)) {
36
- debug('sdc', `Loaded ${data.templates.length} templates from ${p}`);
54
+ debug('agentdeck', `Loaded ${data.templates.length} templates from ${p}`);
37
55
  return data.templates;
38
56
  }
39
57
  }
40
- catch {
41
- // try next
42
- }
58
+ catch { /* try next */ }
43
59
  }
44
60
  }
45
- catch {
46
- // ignore
47
- }
61
+ catch { /* ignore */ }
48
62
  return [];
49
63
  }
50
64
  const promptTemplates = loadTemplates();
51
- // All bridge logging goes to stderr so it doesn't interfere with PTY stdout
52
- function log(msg) {
53
- process.stderr.write(msg + '\n');
54
- }
55
- const program = new Command();
56
- program
57
- .name('sdc')
58
- .description('AgentDeck bridge server')
59
- .version('0.1.0');
60
- // Default command: start bridge + spawn claude + attach terminal
61
- program
62
- .command('start', { isDefault: true })
63
- .description('Start bridge server and spawn agent CLI')
64
- .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
65
- .option('-c, --command <cmd>', 'Command to spawn', 'claude')
66
- .option('-a, --agent <type>', 'Agent type (claude-code|openclaw)', 'claude-code')
67
- .option('-g, --gateway <url>', 'OpenClaw gateway WebSocket URL')
68
- .option('-d, --debug', 'Enable debug logging to /tmp/sdc-debug.log')
69
- .action(async (opts) => {
70
- if (opts.debug) {
71
- enableDebugLog();
72
- log('[sdc] Debug logging enabled → /tmp/sdc-debug.log');
73
- }
74
- const port = parseInt(opts.port, 10);
75
- const agentType = opts.agent;
76
- await startBridge(port, opts.command, agentType, opts.gateway);
77
- });
78
- program
79
- .command('attach')
80
- .description('Attach to an existing bridge session')
81
- .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
82
- .action(async (opts) => {
83
- const port = parseInt(opts.port, 10);
84
- log(`Attaching to bridge on port ${port}...`);
85
- log('Attach mode not yet implemented');
86
- process.exit(1);
87
- });
88
- program
89
- .command('status')
90
- .description('Show bridge and session status')
91
- .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
92
- .action(async (opts) => {
93
- const port = parseInt(opts.port, 10);
94
- try {
95
- const res = await fetch(`http://127.0.0.1:${port}/health`);
96
- const data = await res.json();
97
- log(`Bridge status: ${JSON.stringify(data, null, 2)}`);
98
- }
99
- catch {
100
- log('Bridge is not running');
101
- process.exit(1);
102
- }
103
- });
104
- program
105
- .command('stop')
106
- .description('Stop the bridge and session')
107
- .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
108
- .action(async (opts) => {
109
- const port = parseInt(opts.port, 10);
110
- try {
111
- await fetch(`http://127.0.0.1:${port}/hooks/shutdown`, { method: 'POST' });
112
- log('Shutdown signal sent');
113
- }
114
- catch {
115
- log('Bridge is not running');
116
- }
117
- });
118
- program
119
- .command('qr')
120
- .description('Show pairing URL and QR code for remote clients')
121
- .option('-p, --port <port>', 'Bridge server port (auto-detects from running sessions)')
122
- .action(async (opts) => {
123
- const { getOrCreateToken, getWsUrl } = await import('./auth.js');
124
- const { listActive } = await import('./session-registry.js');
125
- // Determine port: explicit flag > running session > default
126
- let port;
127
- if (opts.port) {
128
- port = parseInt(opts.port, 10);
65
+ // log(), logError(), debug() imported from logger.ts
66
+ // log() is suppressed after setPtyMode(true) — startup messages still shown
67
+ /** Extract tool input for timeline display */
68
+ function formatToolInputForTimeline(toolName, input) {
69
+ if (!input)
70
+ return null;
71
+ const keyMap = {
72
+ Bash: 'command', Read: 'file_path', Write: 'file_path', Edit: 'file_path',
73
+ Glob: 'pattern', Grep: 'pattern', WebFetch: 'url', WebSearch: 'query', Task: 'prompt',
74
+ };
75
+ const key = keyMap[toolName];
76
+ if (key && typeof input[key] === 'string') {
77
+ const line = input[key].split('\n')[0];
78
+ return line.length > 120 ? line.slice(0, 119) + '\u2026' : line;
129
79
  }
130
- else {
131
- const sessions = listActive();
132
- if (sessions.length > 0) {
133
- port = sessions[0].port;
134
- if (sessions.length > 1) {
135
- log(`Multiple sessions running. Using port ${port} (${sessions[0].projectName}).`);
136
- log(`Specify --port to target a different session.`);
137
- }
138
- }
139
- else {
140
- port = BRIDGE_WS_PORT;
80
+ for (const v of Object.values(input)) {
81
+ if (typeof v === 'string' && v.length > 0 && v.length < 200) {
82
+ const line = v.split('\n')[0];
83
+ return line.length > 120 ? line.slice(0, 119) + '\u2026' : line;
141
84
  }
142
85
  }
143
- getOrCreateToken();
144
- const url = getWsUrl(port);
145
- log(`\nPairing URL:\n ${url}\n`);
146
- // Generate text QR in terminal using qrcode lib (if available)
147
- try {
148
- const { default: QRCode } = await import('qrcode');
149
- const text = await QRCode.toString(url, { type: 'terminal', small: true });
150
- log(text);
151
- }
152
- catch {
153
- // qrcode not available in bridge — URL is sufficient
86
+ return null;
87
+ }
88
+ // ===== startSession =====
89
+ export async function startSession(opts) {
90
+ const agentType = opts.agentType;
91
+ if (opts.debug) {
92
+ enableDebugLog();
93
+ log('Debug logging enabled');
154
94
  }
155
- });
156
- program
157
- .command('diag')
158
- .description('Generate diagnostic dump and optionally run AI analysis')
159
- .option('-p, --port <port>', 'Bridge server port', String(BRIDGE_WS_PORT))
160
- .option('-a, --analyze', 'Run AI analysis on the dump')
161
- .option('-t, --tail <lines>', 'Number of journal entries to include', '200')
162
- .action(async (opts) => {
163
- const port = parseInt(opts.port, 10);
164
- const tail = parseInt(opts.tail, 10);
165
- try {
166
- const res = await fetch(`http://127.0.0.1:${port}/diag?tail=${tail}`);
167
- if (!res.ok) {
168
- log(`Diag endpoint error: ${res.status} ${res.statusText}`);
95
+ // Dependency check (skip for monitor — no PTY needed)
96
+ if (agentType !== 'monitor') {
97
+ const deps = checkDependencies(agentType);
98
+ if (!deps.ok)
169
99
  process.exit(1);
170
- }
171
- const dump = await res.json();
172
- // Save dump to disk
173
- const { saveDiagDump, analyzeDump } = await import('./diag-analyzer.js');
174
- const dumpPath = saveDiagDump(dump);
175
- log(`Diagnostic dump saved: ${dumpPath}`);
176
- if (opts.analyze) {
177
- log('Running AI analysis...');
178
- const analysis = await analyzeDump(dumpPath);
179
- if (analysis) {
180
- log('\n--- AI Analysis ---\n');
181
- log(analysis);
182
- }
183
- else {
184
- log('AI analysis failed (is `claude` CLI available?)');
100
+ for (const w of deps.warnings)
101
+ log(`WARNING: ${w}`);
102
+ // Version compatibility check (Claude Code only — uses npm registry + compatibleClaudeCode range)
103
+ if (agentType === 'claude-code' && !opts.noUpdateCheck) {
104
+ const { checkVersionCompatibility } = await import('./version-check.js');
105
+ const versionResult = await checkVersionCompatibility({
106
+ skipCheck: false,
107
+ claudeCodeVersion: deps.agentVersion,
108
+ });
109
+ for (const w of versionResult.warnings)
110
+ log(`WARNING: ${w}`);
111
+ if (versionResult.restartNeeded) {
112
+ log('AgentDeck updated. Please restart.');
113
+ process.exit(0);
185
114
  }
186
115
  }
187
116
  }
188
- catch {
189
- log('Bridge is not running. Cannot generate live diagnostic dump.');
190
- process.exit(1);
191
- }
192
- });
193
- program.parse();
194
- async function startBridge(port, command, agentType, gatewayUrl) {
195
- const deps = checkDependencies();
196
- if (!deps.ok) {
197
- process.exit(1);
198
- }
199
- for (const w of deps.warnings) {
200
- log(`[sdc] WARNING: ${w}`);
117
+ // Multi-session port allocation — session bridges reserve 9120 for the daemon
118
+ const requestedPort = opts.port ?? BRIDGE_WS_PORT;
119
+ let port = requestedPort === BRIDGE_WS_PORT ? await findAvailablePort({ reserveDaemon: true }) : requestedPort;
120
+ // Auto-migrate hooks (Claude Code mode only)
121
+ if (agentType === 'claude-code') {
122
+ migrateHooksIfNeeded();
201
123
  }
202
- // Multi-session: find available port if default is taken
203
- const actualPort = port === BRIDGE_WS_PORT ? await findAvailablePort() : port;
204
- if (actualPort !== port) {
205
- log(`[sdc] Port ${port} in use, using ${actualPort}`);
206
- }
207
- port = actualPort;
208
- // Auto-migrate old-format hooks (hardcoded port → env var)
209
- migrateHooksIfNeeded();
210
- const sessionId = randomUUID();
211
124
  const tmuxSession = detectTmuxSession();
212
125
  const parentTty = (() => {
213
126
  try {
@@ -217,580 +130,1410 @@ async function startBridge(port, command, agentType, gatewayUrl) {
217
130
  return undefined;
218
131
  }
219
132
  })();
220
- const projectName = process.cwd().split('/').pop() || 'unknown';
221
- // Warn if same project is already running in another session
133
+ const projectName = resolveProjectName();
134
+ // Warn if same project already has a non-daemon session running
222
135
  const existingSessions = listActiveSessions();
223
- const sameProject = existingSessions.filter((s) => s.projectName === projectName);
136
+ const sameProject = existingSessions.filter((s) => s.projectName === projectName && s.agentType !== 'daemon');
224
137
  if (sameProject.length > 0) {
225
138
  const ports = sameProject.map((s) => s.port).join(', ');
226
- log(`[sdc] Session "${projectName}" already running on port ${ports}. Starting new session on port ${port}.`);
139
+ log(`\u26A0 Session "${projectName}" already running on port ${ports}. Starting new session on port ${port}.`);
140
+ }
141
+ log(`Starting AgentDeck bridge on port ${port} (agent: ${agentType})...`);
142
+ // ===== Create adapter =====
143
+ const adapter = createAdapter(agentType, opts.gatewayUrl);
144
+ // ===== Start adapter (creates HTTP server, spawns process) =====
145
+ try {
146
+ await adapter.start({ port, command: opts.command, gatewayUrl: opts.gatewayUrl });
147
+ log(`Adapter started: ${adapter.capabilities.displayName}`);
148
+ }
149
+ catch (err) {
150
+ log(`Failed to start adapter: ${err}`);
151
+ process.exit(1);
152
+ }
153
+ // Suppress stderr logging once PTY is active (PTY adapters share terminal)
154
+ // Non-PTY adapters (Monitor, OpenClaw) keep logs visible
155
+ if (adapter.capabilities.hasTerminal) {
156
+ setPtyMode(true);
157
+ }
158
+ // ===== BridgeCore =====
159
+ const core = new BridgeCore({
160
+ port,
161
+ projectName,
162
+ httpServer: adapter.getHttpServer(),
163
+ });
164
+ // ===== APME (Agent Performance Monitoring & Evaluation) =====
165
+ // Optional: degrades to no-op if better-sqlite3 isn't installed.
166
+ const apme = await initApme();
167
+ if (apme) {
168
+ core.setApme(apme, process.cwd());
169
+ log('APME enabled — runs will be logged to ~/.agentdeck/apme.sqlite');
170
+ // Wire APME eval results → timeline entries (session bridge mode).
171
+ // Daemon has its own richer wiring; this covers standalone session bridges.
172
+ apme.runner.onResult(({ runId, turnId }) => {
173
+ if (turnId) {
174
+ // Turn-level eval completed
175
+ const run = apme.store.getRun(runId);
176
+ if (!run)
177
+ return;
178
+ const turnEvals = apme.store.listEvalsForTurn(turnId);
179
+ const overall = turnEvals.find(e => e.metric === 'overall');
180
+ if (!overall)
181
+ return;
182
+ const pct = Math.round(overall.score * 100);
183
+ const axes = turnEvals.filter(e => e.metric !== 'overall');
184
+ const axisStr = axes.map(e => `${e.metric}=${Math.round(e.score * 100)}%`).join(' ');
185
+ core.bridgeTimeline.addEntry({
186
+ ts: Date.now(), type: 'eval_result',
187
+ raw: `★ turn ${pct}% [${run.taskCategory ?? '?'}]`,
188
+ detail: axisStr || `Turn eval · ${run.taskPrompt?.slice(0, 80) ?? ''}`,
189
+ agentType: run.agentType,
190
+ });
191
+ }
192
+ });
227
193
  }
228
- log(`[sdc] Starting AgentDeck bridge on port ${port} (agent: ${agentType})...`);
229
- // API usage data (fetched from Anthropic, not from PTY)
230
- let cachedApiUsage = null;
231
- let lastApiFetchTime = 0;
232
- // Model catalog (OpenClaw: from CLI)
233
- let cachedModelCatalog = null;
234
- // 1. Initialize components
235
- const adapter = createAdapter(agentType, gatewayUrl);
236
- const usageTracker = new UsageTracker();
237
- const stateMachine = new StateMachine(usageTracker);
194
+ // ===== Session-specific components =====
238
195
  const voiceManager = new VoiceManager();
196
+ const utilityProxy = new UtilityProxy();
239
197
  const journal = new EventJournal();
240
198
  const ptyRingBuffer = new PtyRingBuffer();
241
- // 1b. Connect to singleton whisper-server (non-blocking — don't delay bridge startup)
199
+ let cachedSlotMap = null;
200
+ let previousBridgeState = State.IDLE;
201
+ // Timeline log stream (OpenClaw/Claude Code)
202
+ const bridgeLogStream = agentType === 'openclaw' ? new BridgeLogStream() : null;
203
+ // Voice server (non-blocking)
242
204
  voiceManager.connectToServer().catch((err) => {
243
- debug('sdc', `whisper-server connection failed (will use whisper-cli): ${err}`);
205
+ debug('agentdeck', `whisper-server connection failed: ${err}`);
244
206
  });
245
- // 2. Start adapter (creates HTTP server, spawns agent process)
246
- try {
247
- await adapter.start({ port, command, gatewayUrl });
248
- log(`[sdc] Adapter started: ${adapter.capabilities.displayName}`);
249
- }
250
- catch (err) {
251
- log(`[sdc] Failed to start adapter: ${err}`);
252
- process.exit(1);
207
+ // Voice assistant (wake word STT LLM → TTS)
208
+ let voiceAssistant = null;
209
+ if (opts.wakeWord) {
210
+ voiceAssistant = new VoiceAssistantManager({
211
+ sendPrompt: (text) => {
212
+ if (adapter.handleCommand({ type: 'send_prompt', text })) {
213
+ core.stateMachine.handleUserAction('send_prompt');
214
+ }
215
+ else {
216
+ // Fallback for PTY-based adapters
217
+ adapter.writeInput(text);
218
+ setTimeout(() => adapter.writeInput('\r'), 50);
219
+ core.stateMachine.handleUserAction('send_prompt');
220
+ }
221
+ },
222
+ transcribeFile: (filePath) => voiceManager.transcribeFile(filePath),
223
+ isPttRecording: () => voiceManager.isRecording(),
224
+ });
225
+ // Wire state broadcasts
226
+ voiceAssistant.on('state_change', (info) => {
227
+ // Broadcast dedicated event (for plugin FORWARDED_EVENTS)
228
+ core.broadcast({
229
+ type: 'voice_assistant_state',
230
+ state: info.state,
231
+ deviceId: 'mac-builtin',
232
+ text: info.text,
233
+ responseText: info.responseText,
234
+ });
235
+ // Piggyback on state_update so all clients get it automatically
236
+ core.updateVoiceAssistantState(info.state, info.text, info.responseText);
237
+ });
238
+ voiceAssistant.on('wake_word_detected', (info) => {
239
+ core.broadcast({
240
+ type: 'wake_word_detected',
241
+ deviceId: info.deviceId,
242
+ timestamp: info.timestamp,
243
+ });
244
+ });
245
+ // Start (non-blocking)
246
+ voiceAssistant.start().then((ok) => {
247
+ if (ok)
248
+ log('Wake word voice assistant active ("오픈클로")');
249
+ else
250
+ log('Wake word not available (missing model or access key)');
251
+ }).catch((err) => {
252
+ log(`Wake word start failed: ${err}`);
253
+ });
253
254
  }
254
- // 2b. mDNS service advertising (LAN discovery for Android/browser clients)
255
- const mdnsCleanup = advertiseBridge(port, projectName, agentType);
256
- // 2c. Auth token initialization + QR URL for pairing
257
- getOrCreateToken(); // Ensure token exists on disk
258
- const wsUrl = getWsUrl(port);
259
- log(`[sdc] Auth token ready. Pairing URL: ${wsUrl}`);
260
- // 2d. SSE broadcasting helper (only for ClaudeCode adapter which has HookServer)
255
+ // SSE broadcast (ClaudeCodeAdapter / MonitorAdapter have HookServer)
261
256
  let hookServer = null;
262
257
  if (adapter instanceof ClaudeCodeAdapter) {
263
- hookServer = adapter.hookServer;
258
+ hookServer = adapter.getClaudeHookServer();
259
+ hookServer.setMeta({ agentType, projectName });
260
+ hookServer.setVoiceManager(voiceManager);
261
+ hookServer.onApiUsage(() => ({ usage: core.cachedApiUsage, fetchedAt: core.lastApiFetchTime }));
262
+ hookServer.pairingToken = core.authToken;
263
+ }
264
+ else if (adapter instanceof CodexCliAdapter) {
265
+ hookServer = adapter.getCodexHookServer();
266
+ hookServer.setMeta({ agentType, projectName });
267
+ hookServer.setVoiceManager(voiceManager);
268
+ hookServer.pairingToken = core.authToken;
269
+ }
270
+ else if (adapter instanceof OpenCodeAdapter) {
271
+ hookServer = adapter.getHookServer();
264
272
  hookServer.setMeta({ agentType, projectName });
273
+ hookServer.setVoiceManager(voiceManager);
274
+ hookServer.pairingToken = core.authToken;
275
+ }
276
+ else if (adapter instanceof MonitorAdapter) {
277
+ hookServer = adapter.getHookServer();
278
+ hookServer.setMeta({ agentType, projectName });
279
+ hookServer.setVoiceManager(voiceManager);
280
+ hookServer.onApiUsage(() => ({ usage: core.cachedApiUsage, fetchedAt: core.lastApiFetchTime }));
281
+ hookServer.pairingToken = core.authToken;
265
282
  }
266
283
  const broadcastSse = (event) => hookServer?.broadcastSse(event);
267
- // 3. Attach WebSocket server to adapter's HTTP server
268
- const wsServer = new WsServer(adapter.getHttpServer());
269
- log(`[sdc] WebSocket server ready on port ${port}`);
270
- // 3b. Register diag handler
271
- adapter.onDiag((tail) => createDiagDump(stateMachine, wsServer, journal, ptyRingBuffer, tail));
272
- // 3c. Register raw agent data handler for diagnostics
284
+ core.setSseBroadcast(broadcastSse);
285
+ // ===== Display monitor =====
286
+ core.wireDisplayMonitor();
287
+ // ===== Terminal status (tab title + user vars) =====
288
+ const postit = opts.postit !== false && adapter.capabilities.hasTerminal
289
+ ? new TerminalStatus(process.stdout)
290
+ : null;
291
+ // Register process handlers early (before module init) so uncaughtException
292
+ // handler is active before mDNS module starts — suppresses "already in use" errors
293
+ core.registerProcessHandlers('agentdeck');
294
+ // Override unhandledRejection to not shutdown (index.ts original behavior)
295
+ process.removeAllListeners('unhandledRejection');
296
+ process.on('unhandledRejection', (reason) => {
297
+ debug('Bridge', `Unhandled rejection: ${reason}`);
298
+ debug('Bridge', `Unhandled rejection stack: ${reason instanceof Error ? reason.stack : reason}`);
299
+ // Don't shutdown — non-fatal
300
+ });
301
+ // ===== Device modules =====
302
+ const moduleConfigs = opts.modules ?? {
303
+ mdns: false, // daemon-only — session bridges never advertise mDNS
304
+ adb: 'auto',
305
+ serial: false, // daemon-only — session bridges never talk to ESP32
306
+ pixoo: false, // daemon-only — session bridges never talk to Pixoo
307
+ };
308
+ const deviceModules = createDefaultModules(agentType);
309
+ // Set up ESP32 state provider before module init
310
+ let lastStateEvent = null;
311
+ const serialModule = deviceModules.find(m => m.name === 'serial');
312
+ if (serialModule) {
313
+ serialModule.setStateProvider(() => lastStateEvent);
314
+ serialModule.setUsageProvider(() => core.buildUsage());
315
+ serialModule.setInitialStateProvider(() => {
316
+ const events = [];
317
+ if (lastStateEvent)
318
+ events.push(lastStateEvent);
319
+ events.push(core.buildUsage());
320
+ events.push({ type: 'display_state', displayOn: core.displayMonitor.isDisplayOn() });
321
+ core.broadcastSessionsList().catch(() => { });
322
+ return events;
323
+ });
324
+ // Include ESP32 serial connections in client count for polling guards
325
+ core.setExternalClientCountProvider(() => esp32ConnectionCount());
326
+ }
327
+ const startedModules = await initModules(deviceModules, moduleConfigs, {
328
+ port,
329
+ authToken: core.authToken,
330
+ projectName,
331
+ wsServer: core.wsServer,
332
+ broadcastSse,
333
+ });
334
+ // WiFi auto-provisioning for ESP32
335
+ const wifiConfig = loadWifiConfig();
336
+ if (wifiConfig?.autoProvision) {
337
+ const lanIp = getLanIp();
338
+ onESP32Message((portPath, msg) => {
339
+ if (msg.type === 'device_info' && !msg.wifiConnected) {
340
+ sendWifiProvisionToAll({
341
+ type: 'wifi_provision',
342
+ ssid: wifiConfig.ssid,
343
+ password: wifiConfig.password,
344
+ bridgeIp: lanIp,
345
+ bridgePort: port,
346
+ authToken: core.authToken,
347
+ });
348
+ log(`WiFi provision sent to ESP32 on ${portPath}`);
349
+ }
350
+ else if (msg.type === 'wifi_provision_ack') {
351
+ log(msg.success ? `ESP32 WiFi connected: ${msg.ip} \u2713` : `ESP32 WiFi failed: ${msg.error || 'unknown'}`);
352
+ }
353
+ });
354
+ }
355
+ log(`WebSocket server ready on port ${port}`);
356
+ log(`Auth token ready. Pairing URL: ${core.wsUrl}`);
357
+ // Device info getter for GET /devices
358
+ hookServer?.setDeviceInfoGetter(() => ({
359
+ devices: [
360
+ { type: 'websocket', count: core.wsServer.getClientCount() },
361
+ { type: 'esp32', count: esp32ConnectionCount(), ports: getESP32Ports() },
362
+ { type: 'pixoo', details: getPixooDeviceDetails() },
363
+ { type: 'adb', count: getAdbDeviceCount() },
364
+ ],
365
+ }));
366
+ // Pixoo live preview frame getter for GET /pixoo/frame
367
+ hookServer?.setPixooFrameGetter(() => getLastFrame() ?? renderPreviewFrame());
368
+ // ===== Diagnostics =====
369
+ adapter.onDiag((tail) => createDiagDump(core.stateMachine, core.wsServer, journal, ptyRingBuffer, tail));
273
370
  adapter.onRawData((data) => {
274
371
  ptyRingBuffer.push(data);
275
372
  const preview = data.replace(/[\x00-\x1f\x1b]/g, '').slice(0, 200);
276
373
  journal.write('pty_chunk', 'pty', { size: data.length, preview });
277
374
  });
278
- // 3d. Handle VoiceManager errors (prevent uncaught exception crash)
375
+ // Voice errors
279
376
  voiceManager.on('error', (err) => {
280
- debug('sdc', `Voice error: ${err.message}`);
281
- wsServer.broadcast({ type: 'voice_state', state: 'error', error: err.message });
377
+ debug('agentdeck', `Voice error: ${err.message}`);
378
+ core.wsServer.broadcast({ type: 'voice_state', state: 'error', error: err.message });
282
379
  });
283
- // 4. Wire adapter events → StateMachine + journal
380
+ // ===== Timeline wiring =====
381
+ core.wireTimeline(bridgeLogStream ?? undefined);
382
+ // Claude Code hook events → timeline entries
383
+ if (agentType === 'claude-code') {
384
+ wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer);
385
+ }
386
+ // APME wiring for non-Claude-Code agents (OpenCode, Codex, OpenClaw)
387
+ if (apme && agentType !== 'claude-code' && agentType !== 'monitor') {
388
+ wireAgentApme(adapter, agentType, apme, core, ptyRingBuffer);
389
+ }
390
+ // ===== Wire adapter events → StateMachine + journal =====
391
+ // APME: PTY response fallback — captures response from terminal output when
392
+ // the Stop hook doesn't fire (unreliable in Claude Code v2.1+).
393
+ // Three capture paths handle the hook/PTY race condition:
394
+ // Path A: idle fires, turn exists → apply directly
395
+ // Path B: idle fires before hook (fast responses) → buffer, apply on hook arrival
396
+ // Path C: UserPromptSubmit closes prev turn → apply PTY text to closed turn
397
+ let pendingPtyResponse = null;
284
398
  adapter.on('event', (evt) => {
285
399
  switch (evt.source) {
286
400
  case 'hook':
287
401
  journal.write('hook', 'hook', { event: evt.event, data: evt.data });
402
+ // APME ingestion via the shared TelemetrySpan envelope. The Claude
403
+ // hook adapter handles `/clear` (emits a `task_boundary` span) so
404
+ // there's no separate splitRun branch here. Path B/C below preserve
405
+ // the existing PTY-vs-hook race coordination — that's about timing,
406
+ // not ingestion semantics, so it stays in this file.
407
+ if (apme) {
408
+ const ctx = makeApmeAdapterCtx(apme, core.sessionId, agentType);
409
+ for (const span of claudeHookToSpans(ctx, evt.event, evt.data ?? {})) {
410
+ apme.collector.ingestSpan(core.sessionId, span);
411
+ }
412
+ }
413
+ if (evt.event === 'UserPromptSubmit' && apme) {
414
+ // Path B: apply buffered response to the NEW turn (idle fired before hook)
415
+ if (pendingPtyResponse) {
416
+ debug('APME', `hook:UPS Path B: applying pending (${pendingPtyResponse.length} chars)`);
417
+ const ctx = makeApmeAdapterCtx(apme, core.sessionId, agentType);
418
+ const span = claudePtyResponseToSpan(ctx, pendingPtyResponse);
419
+ if (span)
420
+ apme.collector.ingestSpan(core.sessionId, span);
421
+ pendingPtyResponse = null;
422
+ }
423
+ // Path C: prev turn was just closed by ingestHook — apply pending response
424
+ if (pendingPtyResponse) {
425
+ debug('APME', `hook:UPS Path C: applying pending to closed turn (${pendingPtyResponse.length} chars)`);
426
+ const ctx = makeApmeAdapterCtx(apme, core.sessionId, agentType);
427
+ const span = claudePtyResponseToSpan(ctx, pendingPtyResponse, { fallbackToLastClosed: true });
428
+ if (span)
429
+ apme.collector.ingestSpan(core.sessionId, span);
430
+ pendingPtyResponse = null;
431
+ }
432
+ }
433
+ if (evt.event === 'Stop')
434
+ pendingPtyResponse = null; // Stop has cleaner response
288
435
  if (evt.event === 'shutdown') {
289
436
  shutdown();
290
437
  return;
291
438
  }
292
- stateMachine.handleHookEvent(evt.event, evt.data);
439
+ core.stateMachine.handleHookEvent(evt.event, evt.data);
293
440
  break;
294
441
  case 'parser':
295
442
  journal.write('parser_emit', 'pty', { event: evt.event, ...evt.data });
296
- stateMachine.handleParserEvent(evt.event, evt.data);
443
+ core.stateMachine.handleParserEvent(evt.event, evt.data);
444
+ // APME ingestion fallback: PTY parser sees tool use + state changes
445
+ // even when Claude Code hooks are flaky (memory: feedback_apme_stop_hook).
446
+ if (apme && evt.event) {
447
+ const ctx = makeApmeAdapterCtx(apme, core.sessionId, agentType);
448
+ for (const span of claudePtyParserEventToSpans(ctx, evt.event, evt.data ?? {})) {
449
+ apme.collector.ingestSpan(core.sessionId, span);
450
+ }
451
+ }
452
+ // APME: Path A — spinner_stop fires when Claude finishes responding.
453
+ // Delay 500ms to let response render in PTY, then extract text after ⏺ marker.
454
+ if (apme && evt.event === 'spinner_stop') {
455
+ const sid = core.sessionId;
456
+ setTimeout(async () => {
457
+ if (!apme)
458
+ return;
459
+ const tail = ptyRingBuffer.getTail(5000);
460
+ // Claude's response starts with ⏺ — extract content after last ⏺ marker.
461
+ // Take only meaningful lines (stop at spinner chars ✢✻⏸, prompt ❯, or separator ──).
462
+ const marker = tail.lastIndexOf('⏺');
463
+ let response = '';
464
+ if (marker >= 0) {
465
+ const afterMarker = tail.slice(marker + 1).trim();
466
+ const lines = afterMarker.split('\n');
467
+ const clean = [];
468
+ for (const line of lines) {
469
+ const trimmed = line.trim();
470
+ if (!trimmed)
471
+ continue;
472
+ // Stop at UI artifacts: spinner chars (✢✳✶✻✽), plan mode (⏸), prompt (❯), separator (─)
473
+ if (/^[✢✳✶✻✽⏸⏵❯─>]/.test(trimmed))
474
+ break;
475
+ if (/planmode|plan\s*mode|shift\+tab|accept\s*edits/i.test(trimmed))
476
+ break;
477
+ // Status text: "Whirring…", "Finagling…", token count, shortcuts hint
478
+ if (/^\S+…(\s|$)/.test(trimmed) && /tokens|shortcuts|\d+[ms]\s/i.test(trimmed))
479
+ break;
480
+ if (/\?\s*for\s*shortcuts/.test(trimmed))
481
+ break;
482
+ clean.push(trimmed);
483
+ }
484
+ response = clean.join('\n').trim();
485
+ }
486
+ debug('APME', `spinner_stop+500ms: tailLen=${tail.length} marker=${marker} respLen=${response.length}`);
487
+ if (response.length > 2) {
488
+ const turnId = apme.collector.getActiveTurnId(sid);
489
+ if (turnId) {
490
+ const ctx = makeApmeAdapterCtx(apme, sid, agentType);
491
+ const span = claudePtyResponseToSpan(ctx, response);
492
+ if (span)
493
+ apme.collector.ingestSpan(sid, span);
494
+ pendingPtyResponse = null;
495
+ await classifyAndEnqueueTurn(apme, sid);
496
+ }
497
+ else {
498
+ pendingPtyResponse = response;
499
+ }
500
+ }
501
+ }, 500);
502
+ }
297
503
  break;
298
504
  case 'metadata':
299
505
  switch (evt.event) {
300
- case 'cursor_update': {
301
- const idx = evt.data?.cursorIndex ?? 0;
302
- stateMachine.updateCursorIndex(idx, 'pty');
506
+ case 'cursor_update':
507
+ core.stateMachine.updateCursorIndex(evt.data?.cursorIndex ?? 0, 'pty');
303
508
  break;
304
- }
305
509
  case 'usage_info':
306
- usageTracker.setUsageInfo(evt.data);
307
- // Immediately broadcast updated usage
308
- wsServer.broadcast(buildUsageEvent(stateMachine.getSnapshot(), cachedApiUsage));
510
+ core.usageTracker.setUsageInfo(evt.data);
511
+ core.broadcastUsage();
512
+ apme?.collector.updateUsage(core.sessionId, core.usageTracker.getSnapshot());
309
513
  break;
310
514
  case 'user_prompt': {
311
515
  const text = evt.data?.text;
312
- if (text) {
313
- wsServer.broadcast({ type: 'user_prompt', text });
516
+ if (text)
517
+ core.broadcast({ type: 'user_prompt', text });
518
+ // APME: user_prompt is the PTY-detected prompt — use it to open a turn
519
+ // and capture the prompt text (fallback for when hooks don't fire).
520
+ if (apme && text) {
521
+ apme.collector.ingestHook(core.sessionId, 'UserPromptSubmit', { message: { content: text } });
314
522
  }
315
523
  break;
316
524
  }
317
525
  case 'model_catalog': {
318
526
  const models = evt.data?.models;
319
527
  if (models) {
320
- cachedModelCatalog = models;
321
- debug('sdc', `Model catalog updated: ${models.length} models`);
322
- // Broadcast updated state with model catalog
323
- const snap = stateMachine.getSnapshot();
324
- const stateEvt = {
325
- type: 'state_update',
326
- state: snap.state,
327
- permissionMode: snap.permissionMode,
528
+ core.cachedModelCatalog = models;
529
+ debug('agentdeck', `Model catalog updated: ${models.length} models`);
530
+ const snap = core.stateMachine.getSnapshot();
531
+ const stateEvent = core.buildStateEvent({
328
532
  agentType: adapter.capabilities.type,
329
- modelCatalog: cachedModelCatalog ?? undefined,
330
- };
331
- wsServer.broadcast(stateEvt);
533
+ snapshot: snap,
534
+ });
535
+ core.broadcast(stateEvent);
536
+ lastStateEvent = stateEvent;
537
+ core.broadcastUsage();
332
538
  }
333
539
  break;
334
540
  }
335
541
  }
336
542
  break;
337
543
  case 'activity':
338
- stateMachine.onPtyActivity();
544
+ core.stateMachine.onPtyActivity();
339
545
  break;
340
546
  case 'connection': {
341
547
  const connEvt = { type: 'connection', status: evt.status };
342
- wsServer.broadcast(connEvt);
343
- broadcastSse(connEvt);
548
+ core.broadcast(connEvt);
549
+ if (evt.status === 'connected' && bridgeLogStream)
550
+ bridgeLogStream.start();
551
+ else if (evt.status === 'disconnected' && bridgeLogStream)
552
+ bridgeLogStream.stop();
553
+ break;
554
+ }
555
+ case 'timeline': {
556
+ if (evt.upsert) {
557
+ core.bridgeTimeline.upsertEntry(evt.entry);
558
+ }
559
+ else {
560
+ core.bridgeTimeline.addEntry(evt.entry);
561
+ }
562
+ if (evt.entry.type === 'tool_request' && bridgeLogStream) {
563
+ bridgeLogStream.trackToolRequest(evt.entry.raw);
564
+ }
344
565
  break;
345
566
  }
346
567
  }
347
568
  });
348
- // 4b. Handle adapter exit (agent process died)
349
- adapter.on('exit', (_code, _signal) => {
569
+ // Adapter exit always shutdown (shutdownInProgress guard prevents double-shutdown)
570
+ adapter.on('exit', (code, signal) => {
571
+ log(`Agent process exited (code=${code}, signal=${signal})`);
350
572
  shutdown();
351
573
  });
352
- // 5. Wire StateMachine state changes → WsServer broadcast
353
- stateMachine.on('state_changed', (snapshot) => {
574
+ // Default idle button config
575
+ const DEFAULT_IDLE_BUTTONS = [
576
+ { label: 'GO ON', action: 'continue' },
577
+ { label: 'REVIEW', action: '/review' },
578
+ { label: 'COMMIT', action: '/commit' },
579
+ { label: 'CLEAR', action: '/clear' },
580
+ ];
581
+ // ===== Internal WS: push state to daemon =====
582
+ // Port-drift resilient: portProvider is re-invoked on every (re)connect,
583
+ // so the client follows daemon restarts onto fallback ports and the case
584
+ // where the session bridge starts before the daemon is up.
585
+ //
586
+ // Async provider: on cold start the registry may still be empty (session
587
+ // bridge raced the daemon) or the active daemon may be writing its
588
+ // daemon.json into an unreadable dir (App Store group container vs CLI
589
+ // `~/.agentdeck` split). `findDaemonPortAsync` probes 9120-9139 /health
590
+ // as a last resort so the session still attaches instead of staying
591
+ // orphaned.
592
+ const daemonPortProvider = async () => {
593
+ const resolved = await findDaemonPortAsync();
594
+ if (!resolved)
595
+ return null;
596
+ return resolved.port !== core.port ? resolved.port : null;
597
+ };
598
+ const daemonWsClient = new DaemonWsClient(core.sessionId, core.port, agentType, core.projectName, daemonPortProvider);
599
+ daemonWsClient.connect(null);
600
+ core.onShutdown(() => { daemonWsClient.close(); });
601
+ // ===== State changed → broadcast =====
602
+ core.stateMachine.on('state_changed', (snapshot) => {
603
+ postit?.update(snapshot);
604
+ // Voice assistant: reset timeout on any activity during processing
605
+ if (snapshot.state === State.PROCESSING && voiceAssistant?.getState() === 'processing') {
606
+ voiceAssistant.resetResponseTimeout();
607
+ }
608
+ // PROCESSING→IDLE: fetch fresh usage + voice assistant TTS
609
+ const wasActive = previousBridgeState === State.PROCESSING;
610
+ previousBridgeState = snapshot.state;
611
+ if (wasActive && snapshot.state === State.IDLE) {
612
+ if (core.wsServer.getClientCount() > 0 && Date.now() - core.lastApiFetchTime > 10_000) {
613
+ core.fetchAndUpdateUsage().catch(() => { });
614
+ }
615
+ // Voice assistant: if processing a wake-word prompt, speak the response
616
+ if (voiceAssistant?.getState() === 'processing') {
617
+ const lastEntry = core.bridgeTimeline.getLastEntry('chat_end');
618
+ const responseText = lastEntry?.detail;
619
+ if (responseText) {
620
+ voiceAssistant.handleResponse(responseText).catch((err) => {
621
+ debug('agentdeck', `Voice assistant TTS error: ${err}`);
622
+ });
623
+ }
624
+ else {
625
+ // No response text available, return to idle
626
+ voiceAssistant.handleResponse('완료했습니다.').catch(() => { });
627
+ }
628
+ }
629
+ }
630
+ hookServer?.setMeta({
631
+ state: snapshot.state,
632
+ modelName: snapshot.modelName ?? undefined,
633
+ effortLevel: snapshot.effortLevel ?? undefined,
634
+ });
635
+ apme?.collector.updateModel(core.sessionId, snapshot.modelName ?? null);
354
636
  journal.write('state_change', 'internal', { state: snapshot.state, permissionMode: snapshot.permissionMode, suggestedPrompt: snapshot.suggestedPrompt });
355
- // Compute promptType if options are present
356
- let promptType;
637
+ const stateEvent = core.buildStateEvent({
638
+ agentType: adapter.capabilities.type,
639
+ snapshot,
640
+ });
641
+ core.broadcast(stateEvent);
642
+ lastStateEvent = stateEvent;
643
+ core.maybeBroadcastSessionsList();
644
+ // Backward-compat prompt_options
357
645
  if (snapshot.options.length > 0) {
358
- promptType = 'multi_select';
646
+ let promptType = 'multi_select';
359
647
  if (snapshot.state === State.AWAITING_PERMISSION) {
360
648
  promptType = snapshot.options.length > 2 ? 'yes_no_always' : 'yes_no';
361
649
  }
362
650
  else if (snapshot.state === State.AWAITING_DIFF) {
363
651
  promptType = 'diff_review';
364
652
  }
365
- }
366
- // Include options atomically in state_update to avoid race conditions
367
- // Note: agentCapabilities sent only on client connect (static), not on every broadcast
368
- const stateEvent = {
369
- type: 'state_update',
370
- state: snapshot.state,
371
- permissionMode: snapshot.permissionMode,
372
- agentType: adapter.capabilities.type,
373
- currentTool: snapshot.currentTool ?? undefined,
374
- toolInput: snapshot.toolInput ?? undefined,
375
- toolProgress: snapshot.toolProgress ?? undefined,
376
- projectName: snapshot.projectName ?? undefined,
377
- modelName: snapshot.modelName ?? undefined,
378
- billingType: snapshot.billingType,
379
- options: snapshot.options.length > 0 ? snapshot.options : undefined,
380
- promptType,
381
- question: snapshot.question ?? undefined,
382
- navigable: snapshot.navigable || undefined,
383
- cursorIndex: (snapshot.state === State.AWAITING_OPTION ||
384
- snapshot.state === State.AWAITING_PERMISSION ||
385
- snapshot.state === State.AWAITING_DIFF)
386
- ? snapshot.cursorIndex : undefined,
387
- suggestedPrompt: snapshot.suggestedPrompt ?? undefined,
388
- modelCatalog: cachedModelCatalog ?? undefined,
389
- remoteUrl: snapshot.remoteUrl ?? undefined,
390
- pairingUrl: wsUrl,
391
- };
392
- wsServer.broadcast(stateEvent);
393
- broadcastSse(stateEvent);
394
- // Also send separate prompt_options for backward compatibility
395
- if (snapshot.options.length > 0) {
396
- const promptEvent = {
653
+ core.broadcast({
397
654
  type: 'prompt_options',
398
- promptType: promptType,
655
+ promptType,
399
656
  question: snapshot.question ?? undefined,
400
657
  options: snapshot.options,
401
- };
402
- wsServer.broadcast(promptEvent);
658
+ });
403
659
  }
404
- const usageEvt = buildUsageEvent(snapshot, cachedApiUsage);
405
- wsServer.broadcast(usageEvt);
406
- broadcastSse(usageEvt);
660
+ core.broadcastUsage();
661
+ // Push state to daemon via internal WS
662
+ daemonWsClient.pushState(snapshot.state, snapshot.modelName ?? undefined, snapshot.effortLevel ?? undefined);
663
+ // Encoder + button state
664
+ const encEvt = computeEncoderState();
665
+ core.broadcast(encEvt);
666
+ const btnEvt = computeButtonState();
667
+ core.broadcast(btnEvt);
407
668
  });
408
- // 6. Handle PluginCommands from WsServer
409
- wsServer.onCommand((cmd) => {
410
- debug('sdc', `pluginCmd: ${cmd.type}`);
411
- // Let adapter handle commands it owns.
412
- // ClaudeCode: switch_mode, interrupt, escape, respond
413
- // OpenClaw: also select_option, navigate_option, send_prompt (via RPC)
669
+ // ===== Commands from WS clients =====
670
+ core.wsServer.onCommand((cmd) => {
671
+ debug('agentdeck', `pluginCmd: ${cmd.type}`);
672
+ // Adapter-owned commands
414
673
  if (adapter.handleCommand(cmd)) {
415
- // Adapter handled the transport side; update StateMachine as needed
416
674
  switch (cmd.type) {
417
675
  case 'respond':
418
- stateMachine.handleUserAction('respond');
676
+ core.stateMachine.handleUserAction('respond');
419
677
  break;
420
678
  case 'interrupt':
421
- stateMachine.handleUserAction('interrupt');
679
+ core.stateMachine.handleUserAction('interrupt');
422
680
  break;
423
681
  case 'escape':
424
- stateMachine.handleUserAction('interrupt');
682
+ core.stateMachine.handleUserAction('interrupt');
425
683
  break;
426
684
  case 'select_option':
427
- stateMachine.handleUserAction('select_option');
685
+ core.stateMachine.handleUserAction('select_option');
428
686
  break;
429
687
  case 'send_prompt':
430
- stateMachine.handleUserAction('send_prompt');
688
+ core.stateMachine.handleUserAction('send_prompt');
431
689
  break;
432
690
  }
433
691
  return;
434
692
  }
435
- // Commands that need bridge coordination
693
+ // Bridge-coordinated commands
436
694
  switch (cmd.type) {
437
695
  case 'select_option': {
438
- const snapshot = stateMachine.getSnapshot();
439
- debug('sdc', `select_option: idx=${cmd.index} navigable=${snapshot.navigable} cursor=${snapshot.cursorIndex}`);
696
+ const snapshot = core.stateMachine.getSnapshot();
440
697
  if (snapshot.navigable) {
441
- // Arrow-key mode: navigate to desired option then press Enter
442
698
  const delta = cmd.index - snapshot.cursorIndex;
443
699
  if (delta !== 0) {
444
700
  const arrow = delta > 0 ? '\x1b[B' : '\x1b[A';
445
- const steps = Math.abs(delta);
446
- debug('sdc', `select_option: navigating ${steps} steps ${delta > 0 ? 'down' : 'up'}`);
447
- adapter.writeInput(arrow.repeat(steps));
701
+ adapter.writeInput(arrow.repeat(Math.abs(delta)));
448
702
  }
449
- // Proportional delay for PTY to process arrow keys, then confirm with Enter
450
- const delay = 50 + Math.abs(delta) * 20;
451
- setTimeout(() => {
452
- adapter.writeInput('\r');
453
- }, delay);
703
+ setTimeout(() => adapter.writeInput('\r'), 50 + Math.abs(delta) * 20);
454
704
  }
455
705
  else {
456
- // Number input mode: type the 1-based index
457
706
  adapter.writeInput(String(cmd.index + 1) + '\r');
458
707
  }
459
- stateMachine.handleUserAction('select_option');
708
+ core.stateMachine.handleUserAction('select_option');
460
709
  break;
461
710
  }
462
711
  case 'navigate_option': {
463
- const total = stateMachine.getOptionsCount();
464
- const cur = stateMachine.getCursorIndex();
712
+ const total = core.stateMachine.getOptionsCount();
713
+ const cur = core.stateMachine.getCursorIndex();
465
714
  const newIdx = total > 0
466
- ? (cmd.direction === 'up'
467
- ? Math.max(cur - 1, 0)
468
- : Math.min(cur + 1, total - 1))
715
+ ? (cmd.direction === 'up' ? Math.max(cur - 1, 0) : Math.min(cur + 1, total - 1))
469
716
  : cur;
470
- stateMachine.updateCursorIndex(newIdx, 'optimistic');
471
- debug('sdc', `navigate_option: ${cmd.direction} cursor=${cur}->${newIdx}`);
717
+ core.stateMachine.updateCursorIndex(newIdx, 'optimistic');
472
718
  adapter.prepareForNavigation?.();
473
719
  adapter.writeInput(cmd.direction === 'up' ? '\x1b[A' : '\x1b[B');
474
- // Don't call handleUserAction — cursor movement is not a selection
475
720
  break;
476
721
  }
477
722
  case 'send_prompt': {
478
723
  let text = cmd.text;
479
- // Expand template references
480
724
  const templateMatch = text.match(/^__template:(\d+)$/);
481
725
  if (templateMatch) {
482
726
  const idx = parseInt(templateMatch[1], 10);
483
727
  if (idx >= 0 && idx < promptTemplates.length) {
484
728
  text = promptTemplates[idx].prompt;
485
- debug('sdc', `Template ${idx} → "${text.slice(0, 50)}"`);
486
729
  }
487
- else {
488
- debug('sdc', `Template ${idx} out of range (${promptTemplates.length} available)`);
730
+ else
489
731
  break;
490
- }
491
732
  }
492
733
  if (text) {
493
734
  adapter.writeInput(text);
494
735
  setTimeout(() => adapter.writeInput('\r'), 50);
495
- stateMachine.handleUserAction('send_prompt');
736
+ core.stateMachine.handleUserAction('send_prompt');
496
737
  }
497
738
  break;
498
739
  }
740
+ case 'utility':
741
+ utilityProxy.handleCommand(cmd);
742
+ core.broadcast(computeEncoderState());
743
+ break;
499
744
  case 'voice':
500
- handleVoiceCommand(cmd.action, voiceManager, wsServer);
745
+ handleVoiceCommand(cmd.action, voiceManager, core);
501
746
  break;
502
- case 'query_usage': {
503
- // Fetch fresh usage from Anthropic API (no PTY echo)
504
- debug('sdc', 'Fetching usage from API (on demand)');
505
- fetchUsageFromApi().then((apiUsage) => {
506
- if (apiUsage) {
507
- cachedApiUsage = apiUsage;
508
- lastApiFetchTime = Date.now();
509
- if (apiUsage.inferredBillingType) {
510
- stateMachine.inferBillingType(apiUsage.inferredBillingType);
511
- }
512
- }
513
- const snapshot = stateMachine.getSnapshot();
514
- wsServer.broadcast(buildUsageEvent(snapshot, cachedApiUsage));
515
- });
747
+ case 'query_usage':
748
+ core.fetchAndUpdateUsage().catch(() => { });
516
749
  break;
517
- }
518
750
  }
519
751
  });
520
- // 6b. Wire WS connect/disconnect to journal + update SSE metadata
521
- wsServer.onClientDisconnect(() => {
522
- journal.write('ws_event', 'ws', { action: 'disconnect', clients: wsServer.getClientCount() });
523
- hookServer?.setMeta({ clientCount: wsServer.getClientCount() });
752
+ // Deck slot map relay
753
+ core.wsServer.onRawMessage((msg, sender) => {
754
+ if (msg.type === 'deck_slot_map') {
755
+ cachedSlotMap = msg;
756
+ core.wsServer.broadcastExcept(cachedSlotMap, sender);
757
+ broadcastSse(cachedSlotMap);
758
+ core.broadcast(computeButtonState());
759
+ return true;
760
+ }
761
+ return false;
524
762
  });
525
- // Kick initial state: synthetic SessionStart in adapter.start() was emitted before
526
- // the event listener was wired, so fire it explicitly now.
763
+ // WS connect/disconnect journal
764
+ core.wsServer.onClientDisconnect(() => {
765
+ journal.write('ws_event', 'ws', { action: 'disconnect', clients: core.wsServer.getClientCount() });
766
+ hookServer?.setMeta({ clientCount: core.wsServer.getClientCount() });
767
+ });
768
+ // Kick initial state
527
769
  if (adapter.isAlive()) {
528
- stateMachine.handleHookEvent('SessionStart', {});
770
+ core.stateMachine.handleHookEvent('SessionStart', {});
529
771
  }
530
- // Register with session registry for multi-session support
531
- registerSession({
532
- id: sessionId,
533
- port,
534
- pid: process.pid,
535
- projectName: adapter.getProjectName() || projectName,
772
+ // Register session
773
+ core.registerSession(agentType, {
536
774
  tmuxSession,
537
775
  parentTty,
538
776
  tty: adapter.getTtyPath(),
539
- startedAt: new Date().toISOString(),
540
777
  });
541
- // 7. Send current state to newly connected WebSocket clients
542
- wsServer.onClientConnect((ws) => {
543
- journal.write('ws_event', 'ws', { action: 'connect', clients: wsServer.getClientCount() });
544
- hookServer?.setMeta({ clientCount: wsServer.getClientCount() });
545
- const snapshot = stateMachine.getSnapshot();
546
- // Compute promptType for initial state
547
- let initPromptType;
778
+ // ===== Client connect =====
779
+ core.wsServer.onClientConnect((ws) => {
780
+ journal.write('ws_event', 'ws', { action: 'connect', clients: core.wsServer.getClientCount() });
781
+ hookServer?.setMeta({ clientCount: core.wsServer.getClientCount() });
782
+ const snapshot = core.stateMachine.getSnapshot();
783
+ // Restore suggested prompt on reconnect
784
+ let reconnectSuggestion = snapshot.suggestedPrompt;
785
+ if (!reconnectSuggestion && snapshot.state === State.IDLE) {
786
+ reconnectSuggestion = core.stateMachine.getLastValidSuggestedPrompt();
787
+ }
788
+ // Build state event with capabilities for initial connect
789
+ const stateEvent = core.buildStateEvent({
790
+ agentType: adapter.capabilities.type,
791
+ agentCapabilities: adapter.capabilities,
792
+ snapshot,
793
+ });
794
+ // Override suggestedPrompt with reconnect value
795
+ if (reconnectSuggestion) {
796
+ stateEvent.suggestedPrompt = reconnectSuggestion;
797
+ }
798
+ // Extra events for initial state
799
+ const extraEvents = [];
800
+ // Backward-compat prompt_options
548
801
  if (snapshot.options.length > 0) {
549
- initPromptType = 'multi_select';
802
+ let initPromptType = 'multi_select';
550
803
  if (snapshot.state === State.AWAITING_PERMISSION) {
551
804
  initPromptType = snapshot.options.length > 2 ? 'yes_no_always' : 'yes_no';
552
805
  }
553
806
  else if (snapshot.state === State.AWAITING_DIFF) {
554
807
  initPromptType = 'diff_review';
555
808
  }
556
- }
557
- // Restore last valid suggestion on reconnect when IDLE (current suggestedPrompt may already be null)
558
- let reconnectSuggestion = snapshot.suggestedPrompt;
559
- if (!reconnectSuggestion && snapshot.state === State.IDLE) {
560
- reconnectSuggestion = stateMachine.getLastValidSuggestedPrompt();
561
- if (reconnectSuggestion) {
562
- debug('sdc', `Restoring lastValidSuggestedPrompt on reconnect: "${reconnectSuggestion.slice(0, 40)}"`);
563
- }
564
- }
565
- const stateEvent = {
566
- type: 'state_update',
567
- state: snapshot.state,
568
- permissionMode: snapshot.permissionMode,
569
- agentType: adapter.capabilities.type,
570
- agentCapabilities: adapter.capabilities,
571
- currentTool: snapshot.currentTool ?? undefined,
572
- toolInput: snapshot.toolInput ?? undefined,
573
- toolProgress: snapshot.toolProgress ?? undefined,
574
- projectName: snapshot.projectName ?? undefined,
575
- modelName: snapshot.modelName ?? undefined,
576
- billingType: snapshot.billingType,
577
- options: snapshot.options.length > 0 ? snapshot.options : undefined,
578
- promptType: initPromptType,
579
- question: snapshot.question ?? undefined,
580
- navigable: snapshot.navigable || undefined,
581
- cursorIndex: (snapshot.state === State.AWAITING_OPTION ||
582
- snapshot.state === State.AWAITING_PERMISSION ||
583
- snapshot.state === State.AWAITING_DIFF)
584
- ? snapshot.cursorIndex : undefined,
585
- suggestedPrompt: reconnectSuggestion ?? undefined,
586
- modelCatalog: cachedModelCatalog ?? undefined,
587
- pairingUrl: wsUrl,
588
- };
589
- wsServer.sendTo(ws, stateEvent);
590
- // Also send separate prompt_options for backward compatibility
591
- if (snapshot.options.length > 0) {
592
- wsServer.sendTo(ws, {
809
+ extraEvents.push({
593
810
  type: 'prompt_options',
594
811
  promptType: initPromptType,
595
812
  question: snapshot.question ?? undefined,
596
813
  options: snapshot.options,
597
814
  });
598
815
  }
599
- wsServer.sendTo(ws, buildUsageEvent(snapshot, cachedApiUsage));
600
- const connectEvt = {
601
- type: 'connection',
602
- status: adapter.isAlive() ? 'connected' : 'disconnected',
603
- };
604
- wsServer.sendTo(ws, connectEvt);
605
- // Fetch API usage on client connect:
606
- // - Always fetch if no cache yet
607
- // - Re-fetch if cache is stale (>5 min, e.g. after sleep/wake)
608
- const cacheAge = Date.now() - lastApiFetchTime;
609
- const cacheStale = lastApiFetchTime > 0 && cacheAge > 5 * 60 * 1000;
610
- if (!cachedApiUsage || cacheStale) {
611
- fetchUsageFromApi().then((apiUsage) => {
612
- if (apiUsage) {
613
- cachedApiUsage = apiUsage;
614
- lastApiFetchTime = Date.now();
615
- if (apiUsage.inferredBillingType) {
616
- stateMachine.inferBillingType(apiUsage.inferredBillingType);
617
- }
618
- const snap2 = stateMachine.getSnapshot();
619
- wsServer.broadcast(buildUsageEvent(snap2, cachedApiUsage));
620
- }
621
- });
622
- }
816
+ // Encoder + button state
817
+ extraEvents.push(computeEncoderState());
818
+ extraEvents.push(computeButtonState());
819
+ // Slot map
820
+ if (cachedSlotMap)
821
+ extraEvents.push(cachedSlotMap);
822
+ core.sendInitialState(ws, {
823
+ agentType: adapter.capabilities.type,
824
+ agentCapabilities: adapter.capabilities,
825
+ isAlive: adapter.isAlive(),
826
+ extraEvents,
827
+ });
623
828
  });
624
- // 8. Attach user's terminal to adapter (PTY agents proxy stdin/stdout)
829
+ // ===== Terminal attachment =====
625
830
  if (adapter.capabilities.hasTerminal) {
626
- if (process.stdin.isTTY) {
831
+ if (process.stdin.isTTY)
627
832
  process.stdin.setRawMode(true);
628
- }
629
833
  process.stdin.resume();
630
834
  adapter.attachTerminal(process.stdin, process.stdout);
631
835
  }
632
- // 9. Periodic usage update (so session timer ticks on Stream Deck)
633
- const usageInterval = setInterval(() => {
634
- if (wsServer.getClientCount() > 0) {
635
- const snapshot = stateMachine.getSnapshot();
636
- wsServer.broadcast(buildUsageEvent(snapshot, cachedApiUsage));
637
- }
638
- }, 5000);
639
- // 9b. Periodic API usage refresh (silent — no PTY echo)
640
- const apiUsageInterval = setInterval(() => {
641
- if (wsServer.getClientCount() > 0) {
642
- fetchUsageFromApi().then((apiUsage) => {
643
- if (apiUsage) {
644
- cachedApiUsage = apiUsage;
645
- lastApiFetchTime = Date.now();
646
- if (apiUsage.inferredBillingType) {
647
- stateMachine.inferBillingType(apiUsage.inferredBillingType);
836
+ // ===== Polling =====
837
+ core.startUsageTick();
838
+ core.startApiUsagePolling(90_000);
839
+ core.startOllamaProbe();
840
+ core.startMlxProbe();
841
+ core.startAntigravityProbe();
842
+ core.startGatewayProbe(800);
843
+ core.startGatewayHealthCheck();
844
+ core.startSessionsListPolling();
845
+ // Inject virtual OpenClaw session when Gateway is detected (same as daemon-server.ts)
846
+ core.setSessionsEnricher((sessions) => {
847
+ if (!core.cachedGatewayAvailable)
848
+ return sessions;
849
+ if (sessions.some(s => s.agentType === 'openclaw'))
850
+ return sessions;
851
+ return [...sessions, {
852
+ id: 'openclaw-gateway',
853
+ port: 18789,
854
+ projectName: 'OpenClaw',
855
+ agentType: 'openclaw',
856
+ alive: true,
857
+ }];
858
+ });
859
+ // ===== Encoder state computation =====
860
+ function computeEncoderState() {
861
+ const snapshot = core.stateMachine.getSnapshot();
862
+ const isInteractive = snapshot.state === State.AWAITING_OPTION ||
863
+ snapshot.state === State.AWAITING_PERMISSION ||
864
+ snapshot.state === State.AWAITING_DIFF;
865
+ const utilState = utilityProxy.getState();
866
+ const e1 = {
867
+ slot: 0, encoderType: 'utility',
868
+ header: utilState.mode.toUpperCase(),
869
+ value: utilState.value, icon: utilState.icon,
870
+ accentColor: '#3b82f6', progress: utilState.level,
871
+ };
872
+ const e2 = {
873
+ slot: 1, encoderType: 'action',
874
+ header: 'ACTION', accentColor: '#f59e0b',
875
+ };
876
+ if (isInteractive && snapshot.options.length > 0) {
877
+ const opt = snapshot.options[snapshot.cursorIndex];
878
+ e2.header = 'OPTION';
879
+ e2.value = opt?.label ?? '';
880
+ e2.counter = `${snapshot.cursorIndex + 1}/${snapshot.options.length}`;
881
+ e2.detail = opt?.label;
882
+ }
883
+ else if (snapshot.suggestedPrompt) {
884
+ e2.header = 'PROMPT';
885
+ e2.value = snapshot.suggestedPrompt;
886
+ }
887
+ const pct5 = core.cachedApiUsage?.fiveHourPercent ?? null;
888
+ const pct7 = core.cachedApiUsage?.sevenDayPercent ?? null;
889
+ const worstPct = Math.max(pct5 ?? 0, pct7 ?? 0);
890
+ const usageColor = worstPct > 80 ? '#ef4444' : worstPct > 50 ? '#eab308' : '#22c55e';
891
+ const e3 = {
892
+ slot: 2, encoderType: 'usage',
893
+ header: 'USAGE',
894
+ value: pct5 != null ? `${Math.round(pct5)}% / ${Math.round(pct7 ?? 0)}%` : '\u2014',
895
+ accentColor: usageColor,
896
+ progress: worstPct / 100,
897
+ };
898
+ const isRec = voiceManager.isRecording();
899
+ const e4 = {
900
+ slot: 3, encoderType: 'voice',
901
+ header: 'VOICE', value: isRec ? 'Recording...' : 'Ready',
902
+ icon: isRec ? '\uD83D\uDD34' : '\uD83C\uDFA4',
903
+ accentColor: isRec ? '#ef4444' : '#8b5cf6',
904
+ voiceState: isRec ? 'recording' : 'idle',
905
+ };
906
+ return {
907
+ type: 'encoder_state',
908
+ encoders: [e1, e2, e3, e4],
909
+ takeoverActive: isInteractive && snapshot.options.length > 0,
910
+ };
911
+ }
912
+ // ===== Button state computation =====
913
+ function computeButtonState() {
914
+ const snapshot = core.stateMachine.getSnapshot();
915
+ const st = snapshot.state;
916
+ const mode = snapshot.permissionMode;
917
+ const options = snapshot.options;
918
+ const isInteractive = st === State.AWAITING_OPTION ||
919
+ st === State.AWAITING_PERMISSION ||
920
+ st === State.AWAITING_DIFF;
921
+ const DIM = {
922
+ slot: 0, title: '', bgColor: '#1a1a1a', textColor: '#444444',
923
+ enabled: false, dim: true,
924
+ };
925
+ function getIdleButtons() {
926
+ if (!cachedSlotMap)
927
+ return DEFAULT_IDLE_BUTTONS;
928
+ const responseSlots = cachedSlotMap.buttons
929
+ .filter(s => s.actionType === 'response-button')
930
+ .sort((a, b) => a.slot - b.slot);
931
+ if (responseSlots.length === 0)
932
+ return DEFAULT_IDLE_BUTTONS;
933
+ return responseSlots.map((s, i) => ({
934
+ label: s.settings?.label ?? DEFAULT_IDLE_BUTTONS[i]?.label ?? '',
935
+ action: s.settings?.action ?? DEFAULT_IDLE_BUTTONS[i]?.action ?? '',
936
+ }));
937
+ }
938
+ function colorForOption(opt) {
939
+ const shortcut = (opt.shortcut ?? '').toLowerCase();
940
+ const lower = opt.label.toLowerCase();
941
+ if (lower.startsWith('always'))
942
+ return { bg: '#1e40af', text: '#ffffff' };
943
+ if (/don[''\u2019]t\s+ask\s+again/i.test(lower))
944
+ return { bg: '#1e40af', text: '#ffffff' };
945
+ if (/allow\s+all\s+sessions/i.test(lower))
946
+ return { bg: '#1e40af', text: '#ffffff' };
947
+ if (shortcut === 'n' || shortcut === 'd' || lower.startsWith('no') || lower.startsWith('deny'))
948
+ return { bg: '#991b1b', text: '#ffffff' };
949
+ if (shortcut === 'y' || shortcut === 'a')
950
+ return { bg: '#166534', text: '#ffffff' };
951
+ if (opt.recommended)
952
+ return { bg: '#1e4d2b', text: '#86efac' };
953
+ return { bg: '#1e3a5f', text: '#93c5fd' };
954
+ }
955
+ function uppercaseShort(label) {
956
+ return label.length <= 12 ? label.toUpperCase() : label;
957
+ }
958
+ const buttons = [];
959
+ // Slot 0: Mode
960
+ const modeColors = {
961
+ default: '#2a2a2a', plan: '#7c3aed', acceptEdits: '#2563eb',
962
+ dontAsk: '#0e7490', bypassPermissions: '#991b1b',
963
+ };
964
+ const modeLabels = {
965
+ default: 'DEFAULT', plan: 'PLAN', acceptEdits: 'ACCEPT',
966
+ dontAsk: "DON'T ASK", bypassPermissions: 'BYPASS',
967
+ };
968
+ const modeEnabled = st === State.IDLE;
969
+ buttons.push({
970
+ slot: 0,
971
+ title: modeLabels[mode] ?? 'DEFAULT',
972
+ subtitle: 'Mode',
973
+ bgColor: modeEnabled ? (modeColors[mode] ?? '#2a2a2a') : '#1a1a1a',
974
+ textColor: modeEnabled ? '#ffffff' : '#444444',
975
+ enabled: modeEnabled,
976
+ action: modeEnabled ? 'switch_mode' : undefined,
977
+ dim: !modeEnabled,
978
+ });
979
+ // Slot 1: Session/Status
980
+ if (st === State.IDLE) {
981
+ // Skip model-agnostic neutral levels — "medium" (legacy default) and "default"
982
+ // (Claude Code 2.1+ name for the per-model default). Show everything else
983
+ // including max/xhigh/high/low/fast as an informational suffix.
984
+ const effortSuffix = snapshot.effortLevel
985
+ && snapshot.effortLevel !== 'medium'
986
+ && snapshot.effortLevel !== 'default'
987
+ ? snapshot.effortLevel : null;
988
+ const sessionSubtitle = effortSuffix && snapshot.modelName
989
+ ? `${snapshot.modelName} \u00B7 ${effortSuffix}` : snapshot.modelName ?? undefined;
990
+ buttons.push({
991
+ slot: 1, title: snapshot.projectName ?? '\u2014',
992
+ subtitle: sessionSubtitle, bgColor: '#1e293b', textColor: '#ffffff', enabled: false,
993
+ });
994
+ }
995
+ else if (st === State.PROCESSING) {
996
+ buttons.push({
997
+ slot: 1, title: snapshot.currentTool ?? '...',
998
+ subtitle: snapshot.toolProgress ?? undefined,
999
+ bgColor: '#1e3a5f', textColor: '#93c5fd', enabled: false,
1000
+ });
1001
+ }
1002
+ else if (st === State.AWAITING_PERMISSION || st === State.AWAITING_DIFF) {
1003
+ buttons.push({ slot: 1, title: 'PERMIT?', bgColor: '#b45309', textColor: '#ffffff', enabled: false });
1004
+ }
1005
+ else if (st === State.AWAITING_OPTION) {
1006
+ buttons.push({ slot: 1, title: 'SELECT', bgColor: '#b45309', textColor: '#ffffff', enabled: false });
1007
+ }
1008
+ else {
1009
+ buttons.push({ ...DIM, slot: 1 });
1010
+ }
1011
+ // Slot 2: Usage
1012
+ const pct = core.cachedApiUsage?.fiveHourPercent ?? null;
1013
+ const usageText = pct != null ? `${Math.round(pct)}%` : '\u2014';
1014
+ const usageBg = pct == null ? '#1e293b' : pct >= 90 ? '#991b1b' : pct >= 70 ? '#92400e' : '#166534';
1015
+ buttons.push({ slot: 2, title: usageText, subtitle: '5h', bgColor: usageBg, textColor: '#ffffff', enabled: false });
1016
+ // Slots 3-6: Response buttons
1017
+ if (st === State.IDLE) {
1018
+ const idleBtns = getIdleButtons();
1019
+ for (let i = 0; i < 4; i++) {
1020
+ const btn = idleBtns[i];
1021
+ if (btn) {
1022
+ const isGoOn = btn.action === 'continue' || btn.label.toLowerCase() === 'go on';
1023
+ buttons.push({
1024
+ slot: 3 + i, title: btn.label,
1025
+ bgColor: isGoOn ? '#1e3a2f' : '#1e293b',
1026
+ textColor: isGoOn ? '#22c55e' : '#ffffff',
1027
+ enabled: true, action: `command:${btn.action === 'continue' ? 'go on' : btn.action}`,
1028
+ });
1029
+ }
1030
+ else {
1031
+ buttons.push({ ...DIM, slot: 3 + i });
1032
+ }
1033
+ }
1034
+ }
1035
+ else if (st === State.PROCESSING) {
1036
+ for (let i = 0; i < 4; i++)
1037
+ buttons.push({ ...DIM, slot: 3 + i });
1038
+ }
1039
+ else if (isInteractive) {
1040
+ if (options.length === 0 && st === State.AWAITING_PERMISSION) {
1041
+ buttons.push({ slot: 3, title: 'YES', bgColor: '#166534', textColor: '#ffffff', enabled: true, action: 'respond:y' });
1042
+ buttons.push({ slot: 4, title: 'NO', bgColor: '#991b1b', textColor: '#ffffff', enabled: true, action: 'respond:n' });
1043
+ buttons.push({ slot: 5, title: 'ALWAYS', bgColor: '#1e40af', textColor: '#ffffff', enabled: true, action: 'respond:a' });
1044
+ buttons.push({ ...DIM, slot: 6 });
1045
+ }
1046
+ else if (options.length === 0 && st === State.AWAITING_DIFF) {
1047
+ buttons.push({ slot: 3, title: 'APPLY', bgColor: '#166534', textColor: '#ffffff', enabled: true, action: 'respond:a' });
1048
+ buttons.push({ slot: 4, title: 'DENY', bgColor: '#991b1b', textColor: '#ffffff', enabled: true, action: 'respond:d' });
1049
+ buttons.push({ slot: 5, title: 'VIEW', bgColor: '#1e3a5f', textColor: '#93c5fd', enabled: true, action: 'respond:v' });
1050
+ buttons.push({ ...DIM, slot: 6 });
1051
+ }
1052
+ else if (options.length <= 4) {
1053
+ for (let i = 0; i < 4; i++) {
1054
+ if (i < options.length) {
1055
+ const opt = options[i];
1056
+ const colors = (st === State.AWAITING_PERMISSION || st === State.AWAITING_DIFF)
1057
+ ? colorForOption(opt) : opt.recommended ? { bg: '#1e4d2b', text: '#86efac' } : { bg: '#1e3a5f', text: '#93c5fd' };
1058
+ const action = snapshot.navigable
1059
+ ? `select_option:${opt.index}` : `respond:${opt.shortcut || opt.label.charAt(0).toLowerCase()}`;
1060
+ buttons.push({
1061
+ slot: 3 + i, title: uppercaseShort(opt.label),
1062
+ bgColor: colors.bg, textColor: colors.text,
1063
+ enabled: true, badge: opt.recommended ? '\u2605' : opt.selected ? '\u2713' : undefined,
1064
+ action,
1065
+ });
1066
+ }
1067
+ else {
1068
+ buttons.push({ ...DIM, slot: 3 + i });
648
1069
  }
649
- // Broadcast updated usage so clients see fresh rate-limit data
650
- const snapshot = stateMachine.getSnapshot();
651
- wsServer.broadcast(buildUsageEvent(snapshot, cachedApiUsage));
652
1070
  }
653
- });
1071
+ }
1072
+ else {
1073
+ for (let i = 0; i < 3; i++) {
1074
+ const opt = options[i];
1075
+ const colors = (st === State.AWAITING_PERMISSION || st === State.AWAITING_DIFF)
1076
+ ? colorForOption(opt) : opt.recommended ? { bg: '#1e4d2b', text: '#86efac' } : { bg: '#1e3a5f', text: '#93c5fd' };
1077
+ const action = snapshot.navigable
1078
+ ? `select_option:${opt.index}` : `respond:${opt.shortcut || opt.label.charAt(0).toLowerCase()}`;
1079
+ buttons.push({
1080
+ slot: 3 + i, title: uppercaseShort(opt.label),
1081
+ bgColor: colors.bg, textColor: colors.text, enabled: true, action,
1082
+ });
1083
+ }
1084
+ buttons.push({ slot: 6, title: 'MORE \u25BC', bgColor: '#334155', textColor: '#94a3b8', enabled: true, action: 'expand_options' });
1085
+ }
1086
+ }
1087
+ else {
1088
+ for (let i = 0; i < 4; i++)
1089
+ buttons.push({ ...DIM, slot: 3 + i });
654
1090
  }
655
- }, 60_000);
656
- // 10. Graceful shutdown
1091
+ // Slot 7: Stop/ESC
1092
+ if (st === State.PROCESSING) {
1093
+ buttons.push({ slot: 7, title: 'STOP', bgColor: '#cc0000', textColor: '#ffffff', enabled: true, action: 'interrupt' });
1094
+ }
1095
+ else if (isInteractive) {
1096
+ buttons.push({ slot: 7, title: 'ESC', bgColor: '#b45309', textColor: '#ffffff', enabled: true, action: 'escape' });
1097
+ }
1098
+ else if (st === State.IDLE) {
1099
+ buttons.push({ slot: 7, title: 'ESC', bgColor: '#3d2607', textColor: '#ffb347', enabled: true, action: 'escape', dim: false });
1100
+ }
1101
+ else {
1102
+ buttons.push({ ...DIM, slot: 7 });
1103
+ }
1104
+ return { type: 'button_state', buttons };
1105
+ }
1106
+ // ===== Shutdown =====
657
1107
  let shutdownInProgress = false;
658
1108
  function shutdown() {
659
1109
  if (shutdownInProgress)
660
1110
  return;
661
1111
  shutdownInProgress = true;
662
- log('[sdc] Shutting down...');
663
- clearInterval(usageInterval);
664
- clearInterval(apiUsageInterval);
665
- deregisterSession(sessionId);
666
- if (process.stdin.isTTY) {
1112
+ // Hard failsafe — exit no matter what after 3s
1113
+ setTimeout(() => process.exit(0), 3000).unref();
1114
+ log('Shutting down...');
1115
+ if (process.stdin.isTTY)
667
1116
  process.stdin.setRawMode(false);
668
- }
669
- mdnsCleanup();
1117
+ process.stdin.pause();
1118
+ process.stdin.removeAllListeners();
1119
+ postit?.cleanup();
1120
+ utilityProxy.cleanup();
1121
+ voiceAssistant?.stop();
670
1122
  voiceManager.disconnectFromServer();
1123
+ bridgeLogStream?.stop();
671
1124
  journal.close();
672
- wsServer.close();
673
- // Adapter handles killing the agent process and closing its HTTP server
674
- adapter.shutdown().then(() => {
1125
+ core.onShutdown(async () => {
1126
+ await Promise.all([
1127
+ adapter.shutdown(),
1128
+ stopModules(startedModules).catch(() => { }),
1129
+ ]);
675
1130
  process.exit(0);
676
1131
  });
677
- // Force exit if adapter shutdown hangs
678
- setTimeout(() => {
679
- process.exit(1);
680
- }, 3000);
1132
+ core.shutdown();
681
1133
  }
682
- process.on('SIGINT', shutdown);
683
- process.on('SIGTERM', shutdown);
684
- process.on('uncaughtException', (err) => {
685
- log(`[sdc] Uncaught exception: ${err}`);
686
- shutdown();
687
- });
688
- process.on('unhandledRejection', (reason) => {
689
- log(`[sdc] Unhandled rejection: ${reason}`);
690
- shutdown();
1134
+ }
1135
+ // Mid-session classify + turn-level eval enqueue.
1136
+ // Shared between Claude spinner_stop, OpenClaw/OpenCode chat_response, and Codex spinner_stop
1137
+ // so all agents get the same turns.task_category stamping and non-code turn_judge evals.
1138
+ async function classifyAndEnqueueTurn(apme, sid) {
1139
+ const turnId = apme.collector.getActiveTurnId(sid);
1140
+ const runId = apme.collector.getRunId(sid);
1141
+ if (!turnId || !runId)
1142
+ return;
1143
+ const run = apme.store.getRun(runId);
1144
+ if (!run)
1145
+ return;
1146
+ let category = run.taskCategory ?? null;
1147
+ if (!category) {
1148
+ try {
1149
+ const { classifyRun } = await import('./apme/classifier.js');
1150
+ const { category: c, signals } = classifyRun(apme.store, run.id);
1151
+ if (c && c !== 'unknown') {
1152
+ category = c;
1153
+ apme.store.updateRun(run.id, {
1154
+ taskCategory: c,
1155
+ taskSignals: JSON.stringify(signals),
1156
+ taskCategorySource: 'rule',
1157
+ });
1158
+ }
1159
+ }
1160
+ catch (err) {
1161
+ debug('APME', `mid-session classify failed: ${String(err)}`);
1162
+ }
1163
+ }
1164
+ if (category) {
1165
+ try {
1166
+ apme.store.updateTurn(turnId, { taskCategory: category });
1167
+ }
1168
+ catch { /* ignore */ }
1169
+ }
1170
+ const NON_CODE = new Set(['conversation', 'planning', 'research', 'review']);
1171
+ if (category && NON_CODE.has(category)) {
1172
+ apme.runner.enqueueTurn({ runId: run.id, turnId, category });
1173
+ }
1174
+ }
1175
+ // ===== Non-Claude agent APME wiring =====
1176
+ // For OpenCode, Codex, OpenClaw: intercepts adapter timeline events to create
1177
+ // APME turns with prompts and responses. Claude Code uses hooks instead.
1178
+ function wireAgentApme(adapter, agentType, apme, core, ptyRingBuffer) {
1179
+ const sid = core.sessionId;
1180
+ adapter.on('event', (evt) => {
1181
+ // ── Timeline events (OpenCode / OpenClaw) ──
1182
+ // Both adapters emit { source: 'timeline', entry: TimelineEntry }.
1183
+ // The timeline adapter normalizes them to AgentDeck telemetry spans.
1184
+ if (evt.source === 'timeline' && evt.entry) {
1185
+ const entry = evt.entry;
1186
+ const ctx = makeApmeAdapterCtx(apme, sid, agentType);
1187
+ for (const span of timelineEntryToSpans(ctx, entry)) {
1188
+ apme.collector.ingestSpan(sid, span);
1189
+ }
1190
+ // Mid-session classification still runs once we've captured a response.
1191
+ if (entry.type === 'chat_response') {
1192
+ void classifyAndEnqueueTurn(apme, sid);
1193
+ }
1194
+ }
1195
+ // ── Codex: PTY parser fallback ──
1196
+ if (evt.source === 'parser' && agentType === 'codex-cli') {
1197
+ const ctx = makeApmeAdapterCtx(apme, sid, agentType);
1198
+ if (evt.event === 'user_prompt') {
1199
+ const text = evt.data?.text;
1200
+ if (text) {
1201
+ // PTY-derived prompt = synthetic turn_start span. We construct it
1202
+ // inline rather than reusing claudeHookToSpans because the source
1203
+ // is a parser event, not a hook payload.
1204
+ const span = {
1205
+ traceId: ctx.traceId,
1206
+ spanId: randomUUID(),
1207
+ parentSpanId: ctx.activeTurnId,
1208
+ name: spanNameForKind('turn_start'),
1209
+ kind: 'turn_start',
1210
+ ts: Date.now(),
1211
+ attributes: {
1212
+ 'agentdeck.agent_type': ctx.agentType,
1213
+ 'agentdeck.prompt_text': text,
1214
+ ...(ctx.cwd ? { 'agentdeck.cwd': ctx.cwd } : {}),
1215
+ },
1216
+ };
1217
+ apme.collector.ingestSpan(sid, span);
1218
+ }
1219
+ }
1220
+ if (evt.event === 'spinner_stop') {
1221
+ setTimeout(() => {
1222
+ const tail = ptyRingBuffer.getTail(5000);
1223
+ const lines = tail.split('\n').map(l => l.trim()).filter(Boolean);
1224
+ // Filter out spinner/UI artifacts from tail (Codex PTY output)
1225
+ const clean = lines.filter(l => !/^[✢✳✶✻✽⏸⏵❯─>]/.test(l) &&
1226
+ !/planmode|plan\s*mode|shift\+tab|accept\s*edits/i.test(l) &&
1227
+ !/\?\s*for\s*shortcuts/.test(l));
1228
+ const response = clean.slice(-5).join('\n');
1229
+ if (response.length > 2) {
1230
+ const span = claudePtyResponseToSpan(ctx, response);
1231
+ if (span)
1232
+ apme.collector.ingestSpan(sid, span);
1233
+ void classifyAndEnqueueTurn(apme, sid);
1234
+ }
1235
+ }, 500);
1236
+ }
1237
+ }
691
1238
  });
692
1239
  }
693
- function buildUsageEvent(snapshot, apiUsage) {
1240
+ /** Build the AdapterContext used by every APME ingestion adapter. The
1241
+ * trace id mirrors the active run id so future OTLP exports tie spans
1242
+ * emitted in the same session together. `cwd` falls back to `process.cwd()`
1243
+ * because the bridge is started from the project directory. */
1244
+ function makeApmeAdapterCtx(apme, sid, agentType) {
694
1245
  return {
695
- type: 'usage_update',
696
- sessionDurationSec: snapshot.sessionDurationSec,
697
- inputTokens: snapshot.inputTokens,
698
- outputTokens: snapshot.outputTokens,
699
- toolCalls: snapshot.toolCalls,
700
- estimatedCostUsd: snapshot.estimatedCostUsd ?? undefined,
701
- sessionPercent: snapshot.sessionPercent ?? undefined,
702
- costSpent: snapshot.costSpent ?? undefined,
703
- costLimit: snapshot.costLimit ?? undefined,
704
- resetTime: snapshot.resetTime ?? undefined,
705
- resetDate: snapshot.resetDate ?? undefined,
706
- fiveHourPercent: apiUsage?.fiveHourPercent ?? undefined,
707
- fiveHourResetsAt: apiUsage?.fiveHourResetsAt ?? undefined,
708
- sevenDayPercent: apiUsage?.sevenDayPercent ?? undefined,
709
- sevenDayResetsAt: apiUsage?.sevenDayResetsAt ?? undefined,
710
- extraUsageEnabled: apiUsage?.extraUsageEnabled ?? undefined,
711
- extraUsageMonthlyLimit: apiUsage?.extraUsageMonthlyLimit ?? undefined,
712
- extraUsageUsedCredits: apiUsage?.extraUsageUsedCredits ?? undefined,
713
- extraUsageUtilization: apiUsage?.extraUsageUtilization ?? undefined,
1246
+ sessionId: sid,
1247
+ agentType,
1248
+ traceId: apme.collector.getRunId(sid) ?? sid,
1249
+ cwd: process.cwd(),
1250
+ activeTurnId: apme.collector.getActiveTurnId(sid) ?? undefined,
714
1251
  };
715
1252
  }
716
- function handleVoiceCommand(action, voiceManager, wsServer) {
1253
+ // ===== Claude Code timeline wiring =====
1254
+ function wireClaudeCodeTimeline(adapter, core, journal, ptyRingBuffer) {
1255
+ let ccChatStart = null;
1256
+ let ccPendingChatStart = false;
1257
+ let ccPendingChatStartTimer = null;
1258
+ let ccLastPromptText = null;
1259
+ let ccLastHookToolTs = 0; // Change 1: track last hook-based tool_request
1260
+ let ccPendingCompletion = false; // Change 2: track pending chat_end for PTY fallback
1261
+ let ccPendingCompletionTimer = null;
1262
+ const emitChatStart = (text) => {
1263
+ if (ccPendingChatStartTimer) {
1264
+ clearTimeout(ccPendingChatStartTimer);
1265
+ ccPendingChatStartTimer = null;
1266
+ }
1267
+ ccPendingChatStart = false;
1268
+ ccLastPromptText = text || null;
1269
+ const snippet = text.length > 500 ? text.slice(0, 497) + '...' : text;
1270
+ const detail = text.length > 100 ? (text.length > 1000 ? text.slice(0, 1000) + '...' : text) : undefined;
1271
+ // Change 4: better fallback text — include project name
1272
+ const fallbackRaw = (() => {
1273
+ const proj = core.stateMachine.getSnapshot().projectName;
1274
+ return proj ? `Prompt \u00B7 ${proj}` : 'Prompt sent';
1275
+ })();
1276
+ core.bridgeTimeline.addEntry({
1277
+ ts: ccChatStart ?? Date.now(), type: 'chat_start',
1278
+ raw: snippet || fallbackRaw,
1279
+ ...(detail ? { detail } : {}),
1280
+ agentType: 'claude-code',
1281
+ });
1282
+ };
1283
+ /** Extract response text from PTY ringbuffer after ⏺ marker (same pattern as APME) */
1284
+ const extractPtyResponse = () => {
1285
+ const tail = ptyRingBuffer.getTail(5000);
1286
+ const marker = tail.lastIndexOf('⏺');
1287
+ if (marker < 0)
1288
+ return '';
1289
+ const afterMarker = tail.slice(marker + 1).trim();
1290
+ const lines = afterMarker.split('\n');
1291
+ const clean = [];
1292
+ for (const line of lines) {
1293
+ const trimmed = line.trim();
1294
+ if (!trimmed)
1295
+ continue;
1296
+ if (/^[✢✳✶✻✽⏸⏵❯─>]/.test(trimmed))
1297
+ break;
1298
+ if (/planmode|plan\s*mode|shift\+tab|accept\s*edits/i.test(trimmed))
1299
+ break;
1300
+ if (/^\S+…(\s|$)/.test(trimmed) && /tokens|shortcuts|\d+[ms]\s/i.test(trimmed))
1301
+ break;
1302
+ if (/\?\s*for\s*shortcuts/.test(trimmed))
1303
+ break;
1304
+ clean.push(trimmed);
1305
+ }
1306
+ return clean.join('\n').trim();
1307
+ };
1308
+ /** Emit chat_response + chat_end from response text (shared by Stop hook and PTY fallback) */
1309
+ const emitCompletion = (responseText, duration, toolSummary) => {
1310
+ const now = Date.now();
1311
+ // Change 5: emit chat_response if meaningful response text exists
1312
+ if (responseText.length > 20) {
1313
+ const respRaw = responseText.length > 200 ? responseText.slice(0, 197) + '...' : responseText;
1314
+ core.bridgeTimeline.addEntry({
1315
+ ts: now - 1, type: 'chat_response',
1316
+ raw: cleanRawText(respRaw),
1317
+ detail: cleanDetailText(responseText.slice(0, 3000)) || undefined,
1318
+ agentType: 'claude-code',
1319
+ });
1320
+ }
1321
+ const topicHint = responseText ? extractTopicHint(responseText) : null;
1322
+ const promptTopic = ccLastPromptText ? extractTopicHint(ccLastPromptText) : null;
1323
+ const completedLabel = topicHint || promptTopic || 'Completed';
1324
+ let summary = duration != null ? `${completedLabel} \u00B7 ${duration}s` : completedLabel;
1325
+ if (toolSummary)
1326
+ summary += ` \u00B7 ${toolSummary}`;
1327
+ const chatEndDetail = responseText
1328
+ ? (() => { const c = cleanDetailText(responseText); return c ? (c.length > 1000 ? c.slice(0, 1000) + '...' : c) : undefined; })()
1329
+ : ccLastPromptText
1330
+ ? `Prompt: ${ccLastPromptText.length > 200 ? ccLastPromptText.slice(0, 200) + '...' : ccLastPromptText}`
1331
+ : undefined;
1332
+ const chatEndTs = now;
1333
+ ccChatStart = null;
1334
+ ccLastPromptText = null;
1335
+ core.bridgeTimeline.addEntry({
1336
+ ts: chatEndTs, type: 'chat_end', raw: summary,
1337
+ ...(chatEndDetail ? { detail: chatEndDetail } : {}),
1338
+ agentType: 'claude-code',
1339
+ });
1340
+ // Async LLM summarization — fire-and-forget, upsert chat_end when ready
1341
+ if (responseText && responseText.length > 30) {
1342
+ const savedDuration = duration;
1343
+ const savedToolSummary = toolSummary;
1344
+ const savedDetail = chatEndDetail;
1345
+ summarizeResponse(responseText).then((llmSummary) => {
1346
+ if (llmSummary) {
1347
+ const enrichedParts = [llmSummary];
1348
+ if (savedDuration != null)
1349
+ enrichedParts.push(`${savedDuration}s`);
1350
+ if (savedToolSummary)
1351
+ enrichedParts.push(savedToolSummary);
1352
+ debug('timeline', `CC LLM summary: ${llmSummary}`);
1353
+ core.bridgeTimeline.upsertEntry({
1354
+ ts: chatEndTs, type: 'chat_end',
1355
+ raw: enrichedParts.join(' \u00B7 '),
1356
+ ...(savedDetail ? { detail: savedDetail } : {}),
1357
+ agentType: 'claude-code',
1358
+ });
1359
+ }
1360
+ }).catch(() => { });
1361
+ }
1362
+ };
1363
+ // Change 4: late upsert — user_prompt metadata arriving after 500ms timer
1364
+ adapter.on('event', (evt) => {
1365
+ if (evt.source === 'metadata' && evt.event === 'user_prompt') {
1366
+ const text = evt.data?.text || '';
1367
+ if (ccPendingChatStart) {
1368
+ emitChatStart(text);
1369
+ }
1370
+ else if (ccChatStart && Date.now() - ccChatStart < 5000 && text) {
1371
+ // Late arrival — upsert chat_start with actual prompt text
1372
+ const snippet = text.length > 500 ? text.slice(0, 497) + '...' : text;
1373
+ core.bridgeTimeline.upsertEntry({
1374
+ ts: ccChatStart, type: 'chat_start',
1375
+ raw: snippet, agentType: 'claude-code',
1376
+ });
1377
+ ccLastPromptText = text;
1378
+ }
1379
+ }
1380
+ });
1381
+ // Change 1: PTY tool_action → tool_exec timeline entry (fills gap when hooks don't fire)
1382
+ adapter.on('event', (evt) => {
1383
+ if (evt.source !== 'parser' || evt.event !== 'tool_action')
1384
+ return;
1385
+ const now = Date.now();
1386
+ // Skip if hook already emitted tool_request within 2s (avoid duplicates)
1387
+ if (now - ccLastHookToolTs < 2000)
1388
+ return;
1389
+ const toolName = evt.data?.toolName || 'tool';
1390
+ const toolArgs = evt.data?.toolArgs || '';
1391
+ const raw = toolArgs ? `${toolName} ${toolArgs}` : toolName;
1392
+ core.bridgeTimeline.addEntry({
1393
+ ts: now, type: 'tool_exec',
1394
+ raw: raw.length > 500 ? raw.slice(0, 497) + '...' : raw,
1395
+ agentType: 'claude-code',
1396
+ });
1397
+ });
1398
+ // Change 2: PTY fallback chat_end from spinner_stop/idle (when Stop hook doesn't fire)
1399
+ adapter.on('event', (evt) => {
1400
+ if (evt.source !== 'parser')
1401
+ return;
1402
+ if ((evt.event === 'spinner_stop' || evt.event === 'idle') && ccPendingCompletion) {
1403
+ // Wait 1.5s — Stop hook may still arrive
1404
+ if (ccPendingCompletionTimer)
1405
+ clearTimeout(ccPendingCompletionTimer);
1406
+ ccPendingCompletionTimer = setTimeout(() => {
1407
+ ccPendingCompletionTimer = null;
1408
+ if (!ccPendingCompletion)
1409
+ return; // Stop hook already handled it
1410
+ ccPendingCompletion = false;
1411
+ if (ccPendingChatStart)
1412
+ emitChatStart('');
1413
+ const now = Date.now();
1414
+ const duration = ccChatStart ? Math.round((now - ccChatStart) / 1000) : null;
1415
+ const toolSummary = core.usageTracker.getToolSummary();
1416
+ // Extract response from PTY ringbuffer
1417
+ const responseText = extractPtyResponse();
1418
+ debug('timeline', `CC PTY fallback chat_end: respLen=${responseText.length} duration=${duration}`);
1419
+ emitCompletion(responseText, duration, toolSummary);
1420
+ }, 1500);
1421
+ }
1422
+ });
1423
+ adapter.on('event', (evt) => {
1424
+ if (evt.source !== 'hook')
1425
+ return;
1426
+ const now = Date.now();
1427
+ switch (evt.event) {
1428
+ case 'UserPromptSubmit': {
1429
+ ccChatStart = now;
1430
+ core.usageTracker.resetToolCounts();
1431
+ ccPendingChatStart = true;
1432
+ ccPendingCompletion = true; // Change 2: arm PTY fallback
1433
+ // Claude Code v2.1+ sends { message: { content: "..." } }, not { prompt: "..." }
1434
+ const msg = evt.data?.message;
1435
+ const hookText = evt.data?.prompt
1436
+ || evt.data?.text
1437
+ || (typeof msg === 'object' && msg !== null ? msg.content : undefined)
1438
+ || (typeof msg === 'string' ? msg : '')
1439
+ || '';
1440
+ ccPendingChatStartTimer = setTimeout(() => {
1441
+ if (ccPendingChatStart)
1442
+ emitChatStart(hookText);
1443
+ }, 500);
1444
+ break;
1445
+ }
1446
+ case 'PreToolUse': {
1447
+ ccLastHookToolTs = now; // Change 1: mark hook tool time for dedup
1448
+ const toolName = evt.data?.tool_name || 'tool';
1449
+ const formatted = formatToolInputForTimeline(toolName, evt.data?.tool_input);
1450
+ core.bridgeTimeline.addEntry({
1451
+ ts: now, type: 'tool_request',
1452
+ raw: formatted ? `${toolName} ${formatted}` : toolName,
1453
+ agentType: 'claude-code',
1454
+ });
1455
+ break;
1456
+ }
1457
+ case 'PostToolUse': {
1458
+ // Change 3: include tool name instead of generic "Approved"
1459
+ const resolvedTool = evt.data?.tool_name || 'tool';
1460
+ core.bridgeTimeline.addEntry({ ts: now, type: 'tool_resolved', raw: `${resolvedTool} approved`, agentType: 'claude-code' });
1461
+ break;
1462
+ }
1463
+ case 'Stop': {
1464
+ // Race guard: if ccPendingCompletion is already false, the PTY fallback
1465
+ // timer fired first (Stop hook arrived >1.5s after spinner_stop) and
1466
+ // already emitted chat_response/chat_end. Skipping here prevents the
1467
+ // duplicate-line bug visible on the dashboard timeline.
1468
+ const wasPending = ccPendingCompletion;
1469
+ ccPendingCompletion = false; // Change 2: disarm PTY fallback — hook handled it
1470
+ if (ccPendingCompletionTimer) {
1471
+ clearTimeout(ccPendingCompletionTimer);
1472
+ ccPendingCompletionTimer = null;
1473
+ }
1474
+ if (!wasPending) {
1475
+ debug('timeline', 'CC Stop hook arrived after PTY fallback emit — skipping duplicate');
1476
+ break;
1477
+ }
1478
+ if (ccPendingChatStart)
1479
+ emitChatStart('');
1480
+ const duration = ccChatStart ? Math.round((now - ccChatStart) / 1000) : null;
1481
+ const toolSummary = core.usageTracker.getToolSummary();
1482
+ // Response capture priority (CLI bridge only — the App Store Swift
1483
+ // daemon is sandboxed off the projects/ path and relies on
1484
+ // Task 2's response_kind heuristic instead):
1485
+ // 1. transcript_path JSONL — authoritative; Claude Code writes
1486
+ // every user/assistant/tool block here, and the Stop hook
1487
+ // payload points at it.
1488
+ // 2. last_assistant_message — ~18% reliable field on the hook
1489
+ // payload; honoured when the transcript isn't reachable.
1490
+ // 3. PTY ringbuffer — last-resort fallback already handled by
1491
+ // the existing spinner_stop pathway.
1492
+ const transcriptPath = evt.data?.transcript_path || '';
1493
+ let lastAssistantMsg = evt.data?.last_assistant_message || '';
1494
+ if (transcriptPath) {
1495
+ try {
1496
+ const excerpt = readClaudeTranscriptLastTurn(transcriptPath);
1497
+ if (excerpt?.hasAssistantText) {
1498
+ lastAssistantMsg = excerpt.assistantText;
1499
+ }
1500
+ }
1501
+ catch (err) {
1502
+ debug('agentdeck', `transcript read skipped: ${String(err)}`);
1503
+ }
1504
+ }
1505
+ // APME: store Claude's response on the current turn
1506
+ const apmeRef = core.getApme();
1507
+ if (apmeRef && lastAssistantMsg) {
1508
+ apmeRef.collector.setTurnResponse(core.sessionId, lastAssistantMsg);
1509
+ }
1510
+ emitCompletion(lastAssistantMsg, duration, toolSummary);
1511
+ break;
1512
+ }
1513
+ }
1514
+ });
1515
+ }
1516
+ // ===== Voice command handler =====
1517
+ function handleVoiceCommand(action, voiceManager, core) {
717
1518
  switch (action) {
718
1519
  case 'start':
719
1520
  voiceManager.startRecording();
720
- wsServer.broadcast({ type: 'voice_state', state: 'recording' });
1521
+ core.wsServer.broadcast({ type: 'voice_state', state: 'recording' });
721
1522
  break;
722
1523
  case 'stop':
723
- wsServer.broadcast({ type: 'voice_state', state: 'transcribing' });
1524
+ core.wsServer.broadcast({ type: 'voice_state', state: 'transcribing' });
724
1525
  voiceManager.stopRecording().then((text) => {
725
- debug('sdc', `Voice result: "${text?.slice(0, 60) || '(empty)'}"`);
726
- // Don't auto-send — plugin shows review UI; user confirms via send_prompt
727
- wsServer.broadcast({ type: 'voice_state', state: 'idle', text: text || '' });
1526
+ core.wsServer.broadcast({ type: 'voice_state', state: 'idle', text: text || '' });
728
1527
  }).catch((err) => {
729
- debug('sdc', `Voice transcription error: ${err}`);
730
- wsServer.broadcast({ type: 'voice_state', state: 'error', error: String(err) });
1528
+ core.wsServer.broadcast({ type: 'voice_state', state: 'error', error: String(err) });
731
1529
  });
732
1530
  break;
733
1531
  case 'cancel':
734
1532
  voiceManager.cancel();
735
- wsServer.broadcast({ type: 'voice_state', state: 'idle' });
1533
+ core.wsServer.broadcast({ type: 'voice_state', state: 'idle' });
736
1534
  break;
737
1535
  }
738
1536
  }
739
- /**
740
- * Auto-migrate hooks:
741
- * 1. Hardcoded localhost:9120 → $AGENTDECK_PORT env var
742
- * 2. Old flat format → new matcher-group format (Claude Code v2.1+)
743
- * Old: { type: "command", command: "curl ..." }
744
- * New: { matcher: "", hooks: [{ type: "command", command: "curl ..." }] }
745
- */
746
- function migrateHooksIfNeeded() {
747
- const settingsPath = join(homedir(), '.claude', 'settings.local.json');
748
- try {
749
- if (!existsSync(settingsPath))
750
- return;
751
- const raw = readFileSync(settingsPath, 'utf-8');
752
- if (!raw.includes('AGENTDECK_PORT') && !raw.includes('localhost:9120'))
753
- return;
754
- const settings = JSON.parse(raw);
755
- if (!settings.hooks)
756
- return;
757
- let migrated = false;
758
- for (const event of Object.keys(settings.hooks)) {
759
- const hooks = settings.hooks[event];
760
- if (!Array.isArray(hooks))
761
- continue;
762
- for (let i = 0; i < hooks.length; i++) {
763
- const hook = hooks[i];
764
- // Migration 1: hardcoded port → env var
765
- if (hook.command?.includes('localhost:9120') && !hook.command?.includes('AGENTDECK_PORT')) {
766
- hook.command = hook.command.replace(/localhost:9120/g, 'localhost:${AGENTDECK_PORT:-9120}');
767
- migrated = true;
768
- }
769
- // Migration 2: flat format → matcher-group format
770
- // Detect flat format: has "type" + "command" at top level, no "hooks" array
771
- if (hook.type === 'command' && hook.command?.includes('AGENTDECK_PORT') && !hook.hooks) {
772
- const handler = { type: hook.type, command: hook.command };
773
- hooks[i] = { matcher: '', hooks: [handler] };
774
- migrated = true;
775
- }
776
- // Also migrate matcher-group entries with hardcoded port inside
777
- if (Array.isArray(hook.hooks)) {
778
- for (const inner of hook.hooks) {
779
- if (inner.command?.includes('localhost:9120') && !inner.command?.includes('AGENTDECK_PORT')) {
780
- inner.command = inner.command.replace(/localhost:9120/g, 'localhost:${AGENTDECK_PORT:-9120}');
781
- migrated = true;
782
- }
783
- }
784
- }
785
- }
786
- }
787
- if (migrated) {
788
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
789
- log('[sdc] Auto-migrated hooks to v2.1 matcher-group format');
790
- }
791
- }
792
- catch (err) {
793
- debug('sdc', `Hook migration check failed: ${err}`);
794
- }
795
- }
1537
+ // ===== Hook migration =====
1538
+ import { migrateHooksIfNeeded } from '@agentdeck/hooks';
796
1539
  //# sourceMappingURL=index.js.map