@agentdeck/bridge 0.1.0 → 0.2.1

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