@juspay/shooter 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (433) hide show
  1. package/.claude/hooks/notifier.cjs +117 -33
  2. package/.claude/settings.json +14 -14
  3. package/README.md +116 -84
  4. package/bin/shooter.cjs +471 -102
  5. package/build/client/_app/immutable/assets/{0.CM9Hl6d-.css → 0.DC5pAwP3.css} +1 -1
  6. package/build/client/_app/immutable/assets/0.DC5pAwP3.css.br +0 -0
  7. package/build/client/_app/immutable/assets/{0.CM9Hl6d-.css.gz → 0.DC5pAwP3.css.gz} +0 -0
  8. package/build/client/_app/immutable/assets/2.CAShZ7lQ.css.gz +0 -0
  9. package/build/client/_app/immutable/assets/3.BoXp0JoS.css +1 -0
  10. package/build/client/_app/immutable/assets/3.BoXp0JoS.css.br +0 -0
  11. package/build/client/_app/immutable/assets/3.BoXp0JoS.css.gz +0 -0
  12. package/build/client/_app/immutable/assets/4.cJuCkJKZ.css.gz +0 -0
  13. package/build/client/_app/immutable/assets/5.DRjApZQW.css.gz +0 -0
  14. package/build/client/_app/immutable/assets/6.eZGZN-BF.css +1 -0
  15. package/build/client/_app/immutable/assets/6.eZGZN-BF.css.br +0 -0
  16. package/build/client/_app/immutable/assets/6.eZGZN-BF.css.gz +0 -0
  17. package/build/client/_app/immutable/assets/{7.BCJ1IuMx.css → 7.PyEFVv_s.css} +1 -1
  18. package/build/client/_app/immutable/assets/7.PyEFVv_s.css.br +0 -0
  19. package/build/client/_app/immutable/assets/7.PyEFVv_s.css.gz +0 -0
  20. package/build/client/_app/immutable/assets/ChatView.CsdBAOKx.css.gz +0 -0
  21. package/build/client/_app/immutable/assets/Phone.FQEfwCX2.css +1 -0
  22. package/build/client/_app/immutable/assets/Phone.FQEfwCX2.css.br +0 -0
  23. package/build/client/_app/immutable/assets/Phone.FQEfwCX2.css.gz +0 -0
  24. package/build/client/_app/immutable/assets/markdown.Dc-OSJWY.css +1 -0
  25. package/build/client/_app/immutable/assets/markdown.Dc-OSJWY.css.br +0 -0
  26. package/build/client/_app/immutable/assets/markdown.Dc-OSJWY.css.gz +0 -0
  27. package/build/client/_app/immutable/assets/xterm.DFuMZ0ql.css.gz +0 -0
  28. package/build/client/_app/immutable/chunks/{Cjwk_cGO.js → 50RPd5u3.js} +1 -1
  29. package/build/client/_app/immutable/chunks/50RPd5u3.js.br +0 -0
  30. package/build/client/_app/immutable/chunks/50RPd5u3.js.gz +0 -0
  31. package/build/client/_app/immutable/chunks/BDC7XD4o.js +1 -0
  32. package/build/client/_app/immutable/chunks/BDC7XD4o.js.br +0 -0
  33. package/build/client/_app/immutable/chunks/BDC7XD4o.js.gz +0 -0
  34. package/build/client/_app/immutable/chunks/BTGVxaYV.js.gz +0 -0
  35. package/build/client/_app/immutable/chunks/BeONA6_G.js +2 -0
  36. package/build/client/_app/immutable/chunks/BeONA6_G.js.br +0 -0
  37. package/build/client/_app/immutable/chunks/BeONA6_G.js.gz +0 -0
  38. package/build/client/_app/immutable/chunks/BlxrFPDK.js.gz +0 -0
  39. package/build/client/_app/immutable/chunks/BygiiMA0.js +1 -0
  40. package/build/client/_app/immutable/chunks/BygiiMA0.js.br +0 -0
  41. package/build/client/_app/immutable/chunks/BygiiMA0.js.gz +0 -0
  42. package/build/client/_app/immutable/chunks/CGLrx-H5.js.gz +0 -0
  43. package/build/client/_app/immutable/chunks/CHvUpVYv.js +1 -0
  44. package/build/client/_app/immutable/chunks/CHvUpVYv.js.br +0 -0
  45. package/build/client/_app/immutable/chunks/CHvUpVYv.js.gz +0 -0
  46. package/build/client/_app/immutable/chunks/CbqC9BW7.js +1 -0
  47. package/build/client/_app/immutable/chunks/CbqC9BW7.js.br +2 -0
  48. package/build/client/_app/immutable/chunks/CbqC9BW7.js.gz +0 -0
  49. package/build/client/_app/immutable/chunks/CdL99jkG.js +1 -0
  50. package/build/client/_app/immutable/chunks/CdL99jkG.js.br +0 -0
  51. package/build/client/_app/immutable/chunks/CdL99jkG.js.gz +0 -0
  52. package/build/client/_app/immutable/chunks/ClK4wZbC.js +61 -0
  53. package/build/client/_app/immutable/chunks/ClK4wZbC.js.br +0 -0
  54. package/build/client/_app/immutable/chunks/ClK4wZbC.js.gz +0 -0
  55. package/build/client/_app/immutable/chunks/CojFyTPp.js +1 -0
  56. package/build/client/_app/immutable/chunks/CojFyTPp.js.br +0 -0
  57. package/build/client/_app/immutable/chunks/CojFyTPp.js.gz +0 -0
  58. package/build/client/_app/immutable/chunks/DLu6yJIZ.js.gz +0 -0
  59. package/build/client/_app/immutable/chunks/DREFAyhX.js +60 -0
  60. package/build/client/_app/immutable/chunks/DREFAyhX.js.br +0 -0
  61. package/build/client/_app/immutable/chunks/DREFAyhX.js.gz +0 -0
  62. package/build/client/_app/immutable/chunks/DSU1n5N_.js +1 -0
  63. package/build/client/_app/immutable/chunks/DSU1n5N_.js.br +0 -0
  64. package/build/client/_app/immutable/chunks/DSU1n5N_.js.gz +0 -0
  65. package/build/client/_app/immutable/chunks/K_aHH2KN.js +1 -0
  66. package/build/client/_app/immutable/chunks/K_aHH2KN.js.br +0 -0
  67. package/build/client/_app/immutable/chunks/K_aHH2KN.js.gz +0 -0
  68. package/build/client/_app/immutable/chunks/Ona8oofC.js +1 -0
  69. package/build/client/_app/immutable/chunks/Ona8oofC.js.br +0 -0
  70. package/build/client/_app/immutable/chunks/Ona8oofC.js.gz +0 -0
  71. package/build/client/_app/immutable/chunks/PPVm8Dsz.js.gz +0 -0
  72. package/build/client/_app/immutable/chunks/tjbnEgXP.js +1 -0
  73. package/build/client/_app/immutable/chunks/tjbnEgXP.js.br +0 -0
  74. package/build/client/_app/immutable/chunks/tjbnEgXP.js.gz +0 -0
  75. package/build/client/_app/immutable/entry/app.anqwe7ZL.js +2 -0
  76. package/build/client/_app/immutable/entry/app.anqwe7ZL.js.br +0 -0
  77. package/build/client/_app/immutable/entry/app.anqwe7ZL.js.gz +0 -0
  78. package/build/client/_app/immutable/entry/start.BV2VRv6h.js +1 -0
  79. package/build/client/_app/immutable/entry/start.BV2VRv6h.js.br +2 -0
  80. package/build/client/_app/immutable/entry/start.BV2VRv6h.js.gz +0 -0
  81. package/build/client/_app/immutable/nodes/0.D0i5MqcI.js +1 -0
  82. package/build/client/_app/immutable/nodes/0.D0i5MqcI.js.br +0 -0
  83. package/build/client/_app/immutable/nodes/0.D0i5MqcI.js.gz +0 -0
  84. package/build/client/_app/immutable/nodes/1.DN5n5c6F.js +1 -0
  85. package/build/client/_app/immutable/nodes/1.DN5n5c6F.js.br +0 -0
  86. package/build/client/_app/immutable/nodes/1.DN5n5c6F.js.gz +0 -0
  87. package/build/client/_app/immutable/nodes/2.BTjkNKZJ.js +1 -0
  88. package/build/client/_app/immutable/nodes/2.BTjkNKZJ.js.br +0 -0
  89. package/build/client/_app/immutable/nodes/2.BTjkNKZJ.js.gz +0 -0
  90. package/build/client/_app/immutable/nodes/3.CEkigHDv.js +3 -0
  91. package/build/client/_app/immutable/nodes/3.CEkigHDv.js.br +0 -0
  92. package/build/client/_app/immutable/nodes/3.CEkigHDv.js.gz +0 -0
  93. package/build/client/_app/immutable/nodes/4.B-8Zsc9k.js +1 -0
  94. package/build/client/_app/immutable/nodes/4.B-8Zsc9k.js.br +0 -0
  95. package/build/client/_app/immutable/nodes/4.B-8Zsc9k.js.gz +0 -0
  96. package/build/client/_app/immutable/nodes/5.shitbtHc.js +1 -0
  97. package/build/client/_app/immutable/nodes/5.shitbtHc.js.br +0 -0
  98. package/build/client/_app/immutable/nodes/5.shitbtHc.js.gz +0 -0
  99. package/build/client/_app/immutable/nodes/6.CB8Q6eH8.js +2 -0
  100. package/build/client/_app/immutable/nodes/6.CB8Q6eH8.js.br +0 -0
  101. package/build/client/_app/immutable/nodes/6.CB8Q6eH8.js.gz +0 -0
  102. package/build/client/_app/immutable/nodes/7.D0CrR6pl.js +2 -0
  103. package/build/client/_app/immutable/nodes/7.D0CrR6pl.js.br +0 -0
  104. package/build/client/_app/immutable/nodes/7.D0CrR6pl.js.gz +0 -0
  105. package/build/client/_app/version.json +1 -1
  106. package/build/client/_app/version.json.br +0 -0
  107. package/build/client/_app/version.json.gz +0 -0
  108. package/build/client/favicon.svg.gz +0 -0
  109. package/build/pty-holder.cjs +37 -8
  110. package/build/server/chunks/0-Cs1dzfRz.js +9 -0
  111. package/build/server/chunks/{0-q2IUp76Y.js.map → 0-Cs1dzfRz.js.map} +1 -1
  112. package/build/server/chunks/1-BCSX7oED.js +9 -0
  113. package/build/server/chunks/{1-CU50G5wZ.js.map → 1-BCSX7oED.js.map} +1 -1
  114. package/build/server/chunks/2-6gkeO8b4.js +9 -0
  115. package/build/server/chunks/2-6gkeO8b4.js.map +1 -0
  116. package/build/server/chunks/3-DH9J9Vsc.js +9 -0
  117. package/build/server/chunks/3-DH9J9Vsc.js.map +1 -0
  118. package/build/server/chunks/4-Rzy5TYjl.js +9 -0
  119. package/build/server/chunks/4-Rzy5TYjl.js.map +1 -0
  120. package/build/server/chunks/5-D0wB7nfE.js +9 -0
  121. package/build/server/chunks/5-D0wB7nfE.js.map +1 -0
  122. package/build/server/chunks/6-DR2ABDPq.js +9 -0
  123. package/build/server/chunks/6-DR2ABDPq.js.map +1 -0
  124. package/build/server/chunks/7-D-oQJsia.js +9 -0
  125. package/build/server/chunks/7-D-oQJsia.js.map +1 -0
  126. package/build/server/chunks/Button-C7D5W6wV.js +80 -0
  127. package/build/server/chunks/Button-C7D5W6wV.js.map +1 -0
  128. package/build/server/chunks/EmptyState-Ci4pSpmY.js +20 -0
  129. package/build/server/chunks/EmptyState-Ci4pSpmY.js.map +1 -0
  130. package/build/server/chunks/Icon-DyrkHVnV.js +36 -0
  131. package/build/server/chunks/Icon-DyrkHVnV.js.map +1 -0
  132. package/build/server/chunks/Shimmer-BITK6wrm.js +10 -0
  133. package/build/server/chunks/Shimmer-BITK6wrm.js.map +1 -0
  134. package/build/server/chunks/_layout.svelte-CO4f8UD7.js +76 -0
  135. package/build/server/chunks/_layout.svelte-CO4f8UD7.js.map +1 -0
  136. package/build/server/chunks/_page.svelte-3Cc3NMAP.js +81 -0
  137. package/build/server/chunks/_page.svelte-3Cc3NMAP.js.map +1 -0
  138. package/build/server/chunks/_page.svelte-C0p3HsIW.js +46 -0
  139. package/build/server/chunks/_page.svelte-C0p3HsIW.js.map +1 -0
  140. package/build/server/chunks/_page.svelte-C6bns9aQ.js +41 -0
  141. package/build/server/chunks/_page.svelte-C6bns9aQ.js.map +1 -0
  142. package/build/server/chunks/_page.svelte-CFCONiDK.js +97 -0
  143. package/build/server/chunks/_page.svelte-CFCONiDK.js.map +1 -0
  144. package/build/server/chunks/_page.svelte-DskND_G9.js +550 -0
  145. package/build/server/chunks/_page.svelte-DskND_G9.js.map +1 -0
  146. package/build/server/chunks/_page.svelte-rTrWmhOp.js +651 -0
  147. package/build/server/chunks/_page.svelte-rTrWmhOp.js.map +1 -0
  148. package/build/server/chunks/{_server.ts-CbDRDIoP.js → _server.ts-BStnNIcq.js} +9 -11
  149. package/build/server/chunks/_server.ts-BStnNIcq.js.map +1 -0
  150. package/build/server/chunks/{_server.ts-C29xzfaw.js → _server.ts-C8slOHB0.js} +9 -10
  151. package/build/server/chunks/_server.ts-C8slOHB0.js.map +1 -0
  152. package/build/server/chunks/{_server.ts-CPa6DgIt.js → _server.ts-COu0vNpd.js} +6 -6
  153. package/build/server/chunks/_server.ts-COu0vNpd.js.map +1 -0
  154. package/build/server/chunks/{_server.ts-DRVbgm6k.js → _server.ts-CS5H5klP.js} +10 -17
  155. package/build/server/chunks/_server.ts-CS5H5klP.js.map +1 -0
  156. package/build/server/chunks/{_server.ts-D4MNi4cD.js → _server.ts-Cf84YIaW.js} +3 -3
  157. package/build/server/chunks/{_server.ts-D4MNi4cD.js.map → _server.ts-Cf84YIaW.js.map} +1 -1
  158. package/build/server/chunks/{_server.ts-BL2FGb5Z.js → _server.ts-Ch-6iOHp.js} +99 -53
  159. package/build/server/chunks/_server.ts-Ch-6iOHp.js.map +1 -0
  160. package/build/server/chunks/{_server.ts-DfajWaqh.js → _server.ts-DV8zTCF9.js} +7 -9
  161. package/build/server/chunks/_server.ts-DV8zTCF9.js.map +1 -0
  162. package/build/server/chunks/{_server.ts-ColfDHW8.js → _server.ts-DYvb9ijZ.js} +21 -10
  163. package/build/server/chunks/_server.ts-DYvb9ijZ.js.map +1 -0
  164. package/build/server/chunks/_server.ts-DhTrdlWH.js +62 -0
  165. package/build/server/chunks/_server.ts-DhTrdlWH.js.map +1 -0
  166. package/build/server/chunks/{_server.ts-Cv_OrRuL.js → _server.ts-DpEVfp8W.js} +165 -8
  167. package/build/server/chunks/_server.ts-DpEVfp8W.js.map +1 -0
  168. package/build/server/chunks/{_server.ts-y9-WYDMa.js → _server.ts-WhTJBEJy.js} +5 -4
  169. package/build/server/chunks/{_server.ts-y9-WYDMa.js.map → _server.ts-WhTJBEJy.js.map} +1 -1
  170. package/build/server/chunks/{_server.ts-BjOJsoy4.js → _server.ts-XzT2UHM1.js} +6 -5
  171. package/build/server/chunks/_server.ts-XzT2UHM1.js.map +1 -0
  172. package/build/server/chunks/{_server.ts-BgdjBZco.js → _server.ts-tSpgyl1D.js} +7 -5
  173. package/build/server/chunks/_server.ts-tSpgyl1D.js.map +1 -0
  174. package/build/server/chunks/{_server.ts-BihKSdj_.js → _server.ts-uHUi-4cd.js} +7 -8
  175. package/build/server/chunks/_server.ts-uHUi-4cd.js.map +1 -0
  176. package/build/server/chunks/{auth-CEgFis71.js → auth-DeCdZ83n.js} +2 -2
  177. package/build/server/chunks/{auth-CEgFis71.js.map → auth-DeCdZ83n.js.map} +1 -1
  178. package/build/server/chunks/client-BYT9c0ig.js +7 -0
  179. package/build/server/chunks/client-BYT9c0ig.js.map +1 -0
  180. package/build/server/chunks/client2-BjxIYuno.js +25 -0
  181. package/build/server/chunks/client2-BjxIYuno.js.map +1 -0
  182. package/build/server/chunks/error-DDXB3duW.js +12 -0
  183. package/build/server/chunks/error-DDXB3duW.js.map +1 -0
  184. package/build/server/chunks/error.svelte-BG_yE-Wt.js +27 -0
  185. package/build/server/chunks/error.svelte-BG_yE-Wt.js.map +1 -0
  186. package/build/server/chunks/{exports-CJ0Q5XmL.js → index-Cnl871UW.js} +1111 -1634
  187. package/build/server/chunks/index-Cnl871UW.js.map +1 -0
  188. package/build/server/chunks/index-server-CUC9Jt7r.js +9 -0
  189. package/build/server/chunks/index-server-CUC9Jt7r.js.map +1 -0
  190. package/build/server/chunks/index2-HA3VTH7y.js +58 -0
  191. package/build/server/chunks/index2-HA3VTH7y.js.map +1 -0
  192. package/build/server/chunks/{library-apns-BHxLmuIx.js → library-apns-BqJbvSKh.js} +4 -4
  193. package/build/server/chunks/library-apns-BqJbvSKh.js.map +1 -0
  194. package/build/server/chunks/markdown-W_mTBct0.js +8 -0
  195. package/build/server/chunks/markdown-W_mTBct0.js.map +1 -0
  196. package/build/server/chunks/{pty-manager-C0FhBiVq.js → pty-manager-DR0Wt2Ac.js} +146 -319
  197. package/build/server/chunks/pty-manager-DR0Wt2Ac.js.map +1 -0
  198. package/build/server/chunks/root-DhswcH6o.js +1171 -0
  199. package/build/server/chunks/root-DhswcH6o.js.map +1 -0
  200. package/build/server/chunks/{shared-server-BDY8jh20.js → shared-server-sSGG17Df.js} +2 -3
  201. package/build/server/chunks/{shared-server-BDY8jh20.js.map → shared-server-sSGG17Df.js.map} +1 -1
  202. package/build/server/chunks/state.svelte-2Lellg7t.js +11 -0
  203. package/build/server/chunks/state.svelte-2Lellg7t.js.map +1 -0
  204. package/build/server/index.js +1085 -2242
  205. package/build/server/index.js.map +1 -1
  206. package/build/server/manifest.js +23 -23
  207. package/build/server/manifest.js.map +1 -1
  208. package/package.json +32 -9
  209. package/scripts/fix-generated-types.sh +37 -0
  210. package/scripts/homebrew/shooter.rb +51 -0
  211. package/scripts/install.sh +348 -186
  212. package/scripts/setup.cjs +139 -43
  213. package/server.ts +111 -71
  214. package/src/app.css +12 -3
  215. package/src/app.d.ts +13 -20
  216. package/src/app.html +0 -2
  217. package/src/generated/types/API.ts +280 -0
  218. package/src/generated/types/APN.ts +186 -203
  219. package/src/generated/types/CLI.ts +18 -25
  220. package/src/generated/types/Client.ts +589 -0
  221. package/src/generated/types/Config.ts +53 -0
  222. package/src/generated/types/Holder.ts +638 -0
  223. package/src/generated/types/JWT.ts +39 -50
  224. package/src/generated/types/Notification.ts +426 -0
  225. package/src/generated/types/OpenCode.ts +356 -0
  226. package/src/generated/types/Sessions.ts +570 -0
  227. package/src/generated/types/Terminal.ts +2184 -2071
  228. package/src/generated/types/WsProtocol.ts +2004 -0
  229. package/src/generated/types/index.ts +9 -3
  230. package/src/lib/env.ts +29 -0
  231. package/src/lib/modules/client/common/config-guard.ts +37 -5
  232. package/src/lib/modules/client/common/error.ts +10 -0
  233. package/src/lib/modules/client/common/index.ts +6 -5
  234. package/src/lib/modules/client/common/native-bridge.ts +28 -20
  235. package/src/lib/modules/client/terminal/ChatView.svelte +46 -23
  236. package/src/lib/modules/client/terminal/CommandPalette.svelte +3 -2
  237. package/src/lib/modules/client/terminal/ConnectionStatus.svelte +6 -1
  238. package/src/lib/modules/client/terminal/LaunchSheet.svelte +147 -84
  239. package/src/lib/modules/client/terminal/QuickKeys.svelte +3 -1
  240. package/src/lib/modules/client/terminal/ShortcutsHelp.svelte +2 -5
  241. package/src/lib/modules/client/terminal/keyboard-shortcuts.ts +27 -24
  242. package/src/lib/modules/client/terminal/xterm-wrapper.ts +70 -44
  243. package/src/lib/modules/server/apn/library-apns.ts +3 -2
  244. package/src/lib/modules/server/apn/notification-history.ts +2 -13
  245. package/src/lib/modules/server/apn/notification-sessions.ts +3 -13
  246. package/src/lib/modules/server/apn/pending-requests.ts +3 -8
  247. package/src/lib/modules/server/apn/types.ts +5 -4
  248. package/src/lib/modules/server/cli/index.ts +3 -2
  249. package/src/lib/modules/server/fcm/fcm-service.ts +8 -6
  250. package/src/lib/modules/server/sessions/jsonl-parser.ts +3 -3
  251. package/src/lib/modules/server/sessions/jsonl-reader.ts +60 -15
  252. package/src/lib/modules/server/sessions/types.ts +11 -22
  253. package/src/lib/modules/server/terminal/holder-client.ts +272 -248
  254. package/src/lib/modules/server/terminal/opencode-watcher.ts +555 -553
  255. package/src/lib/modules/server/terminal/pty-holder.cjs +37 -8
  256. package/src/lib/modules/server/terminal/pty-manager.ts +141 -111
  257. package/src/lib/modules/server/terminal/session-watcher.ts +6 -4
  258. package/src/lib/modules/server/terminal/terminal-store.ts +131 -128
  259. package/src/lib/modules/server/utils/error.ts +9 -0
  260. package/src/lib/modules/server/ws/events-handler.ts +12 -6
  261. package/src/lib/modules/server/ws/keepalive.ts +86 -69
  262. package/src/lib/modules/server/ws/server.ts +43 -37
  263. package/src/lib/modules/server/ws/session-handler.ts +52 -45
  264. package/src/lib/modules/server/ws/terminal-handler.ts +29 -17
  265. package/src/lib/modules/server/ws/ticket-store.ts +29 -26
  266. package/src/lib/types/config.ts +1 -6
  267. package/src/routes/+layout.svelte +66 -31
  268. package/src/routes/+page.svelte +8 -15
  269. package/src/routes/api/debug/+server.ts +3 -1
  270. package/src/routes/api/device-token/+server.ts +60 -60
  271. package/src/routes/api/health/+server.ts +69 -73
  272. package/src/routes/api/notify/+server.ts +115 -68
  273. package/src/routes/api/qr-config/+server.ts +30 -32
  274. package/src/routes/api/response/+server.ts +9 -4
  275. package/src/routes/api/sessions/+server.ts +3 -1
  276. package/src/routes/api/terminals/+server.ts +14 -15
  277. package/src/routes/api/terminals/[id]/+server.ts +13 -7
  278. package/src/routes/api/terminals/[id]/paste-image/+server.ts +54 -52
  279. package/src/routes/api/terminals/[id]/resize/+server.ts +6 -3
  280. package/src/routes/api/webhook/+server.ts +8 -10
  281. package/src/routes/api/ws-status/+server.ts +7 -5
  282. package/src/routes/api/ws-ticket/+server.ts +42 -41
  283. package/src/routes/config/+page.svelte +129 -76
  284. package/src/routes/project/+page.svelte +7 -28
  285. package/src/routes/session/[id]/+page.svelte +70 -52
  286. package/src/routes/terminals/+page.svelte +45 -43
  287. package/src/routes/terminals/[id]/+page.svelte +198 -91
  288. package/build/client/_app/immutable/assets/0.CM9Hl6d-.css.br +0 -0
  289. package/build/client/_app/immutable/assets/3.C0uFg0IS.css +0 -1
  290. package/build/client/_app/immutable/assets/3.C0uFg0IS.css.br +0 -0
  291. package/build/client/_app/immutable/assets/3.C0uFg0IS.css.gz +0 -0
  292. package/build/client/_app/immutable/assets/6.AraZrY8I.css +0 -1
  293. package/build/client/_app/immutable/assets/6.AraZrY8I.css.br +0 -0
  294. package/build/client/_app/immutable/assets/6.AraZrY8I.css.gz +0 -0
  295. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.br +0 -0
  296. package/build/client/_app/immutable/assets/7.BCJ1IuMx.css.gz +0 -0
  297. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css +0 -1
  298. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.br +0 -0
  299. package/build/client/_app/immutable/assets/markdown.B0b5w2tq.css.gz +0 -0
  300. package/build/client/_app/immutable/chunks/BNJphC1q.js +0 -56
  301. package/build/client/_app/immutable/chunks/BNJphC1q.js.br +0 -0
  302. package/build/client/_app/immutable/chunks/BNJphC1q.js.gz +0 -0
  303. package/build/client/_app/immutable/chunks/Bvk7mfPM.js +0 -1
  304. package/build/client/_app/immutable/chunks/Bvk7mfPM.js.br +0 -0
  305. package/build/client/_app/immutable/chunks/Bvk7mfPM.js.gz +0 -0
  306. package/build/client/_app/immutable/chunks/CAokzuPQ.js +0 -1
  307. package/build/client/_app/immutable/chunks/CAokzuPQ.js.br +0 -0
  308. package/build/client/_app/immutable/chunks/CAokzuPQ.js.gz +0 -0
  309. package/build/client/_app/immutable/chunks/CgCpWzEA.js +0 -1
  310. package/build/client/_app/immutable/chunks/CgCpWzEA.js.br +0 -0
  311. package/build/client/_app/immutable/chunks/CgCpWzEA.js.gz +0 -0
  312. package/build/client/_app/immutable/chunks/Cjwk_cGO.js.br +0 -0
  313. package/build/client/_app/immutable/chunks/Cjwk_cGO.js.gz +0 -0
  314. package/build/client/_app/immutable/chunks/CtQ8EED1.js +0 -11
  315. package/build/client/_app/immutable/chunks/CtQ8EED1.js.br +0 -0
  316. package/build/client/_app/immutable/chunks/CtQ8EED1.js.gz +0 -0
  317. package/build/client/_app/immutable/chunks/DERQCisl.js +0 -1
  318. package/build/client/_app/immutable/chunks/DERQCisl.js.br +0 -0
  319. package/build/client/_app/immutable/chunks/DERQCisl.js.gz +0 -0
  320. package/build/client/_app/immutable/chunks/DKrg8TQs.js +0 -1
  321. package/build/client/_app/immutable/chunks/DKrg8TQs.js.br +0 -0
  322. package/build/client/_app/immutable/chunks/DKrg8TQs.js.gz +0 -0
  323. package/build/client/_app/immutable/chunks/Dkkpz_4D.js +0 -126
  324. package/build/client/_app/immutable/chunks/Dkkpz_4D.js.br +0 -0
  325. package/build/client/_app/immutable/chunks/Dkkpz_4D.js.gz +0 -0
  326. package/build/client/_app/immutable/chunks/DoczjQhA.js +0 -1
  327. package/build/client/_app/immutable/chunks/DoczjQhA.js.br +0 -0
  328. package/build/client/_app/immutable/chunks/DoczjQhA.js.gz +0 -0
  329. package/build/client/_app/immutable/chunks/RpcNruLP.js +0 -2
  330. package/build/client/_app/immutable/chunks/RpcNruLP.js.br +0 -0
  331. package/build/client/_app/immutable/chunks/RpcNruLP.js.gz +0 -0
  332. package/build/client/_app/immutable/chunks/a-St0Zwo.js +0 -1
  333. package/build/client/_app/immutable/chunks/a-St0Zwo.js.br +0 -0
  334. package/build/client/_app/immutable/chunks/a-St0Zwo.js.gz +0 -0
  335. package/build/client/_app/immutable/chunks/bo70OQUZ.js +0 -1
  336. package/build/client/_app/immutable/chunks/bo70OQUZ.js.br +0 -0
  337. package/build/client/_app/immutable/chunks/bo70OQUZ.js.gz +0 -0
  338. package/build/client/_app/immutable/entry/app.QvGgdvTI.js +0 -2
  339. package/build/client/_app/immutable/entry/app.QvGgdvTI.js.br +0 -0
  340. package/build/client/_app/immutable/entry/app.QvGgdvTI.js.gz +0 -0
  341. package/build/client/_app/immutable/entry/start.BntDNRMC.js +0 -1
  342. package/build/client/_app/immutable/entry/start.BntDNRMC.js.br +0 -0
  343. package/build/client/_app/immutable/entry/start.BntDNRMC.js.gz +0 -0
  344. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js +0 -1
  345. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.br +0 -0
  346. package/build/client/_app/immutable/nodes/0.CzkdvJ7j.js.gz +0 -0
  347. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js +0 -1
  348. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.br +0 -0
  349. package/build/client/_app/immutable/nodes/1.MG1QhfrI.js.gz +0 -0
  350. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js +0 -1
  351. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.br +0 -0
  352. package/build/client/_app/immutable/nodes/2.B4MlOSh6.js.gz +0 -0
  353. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js +0 -3
  354. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.br +0 -0
  355. package/build/client/_app/immutable/nodes/3.DIwYkjDn.js.gz +0 -0
  356. package/build/client/_app/immutable/nodes/4.D-cIe70D.js +0 -1
  357. package/build/client/_app/immutable/nodes/4.D-cIe70D.js.br +0 -0
  358. package/build/client/_app/immutable/nodes/4.D-cIe70D.js.gz +0 -0
  359. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js +0 -1
  360. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.br +0 -0
  361. package/build/client/_app/immutable/nodes/5.D7zPRe3L.js.gz +0 -0
  362. package/build/client/_app/immutable/nodes/6.BB7QE48r.js +0 -2
  363. package/build/client/_app/immutable/nodes/6.BB7QE48r.js.br +0 -0
  364. package/build/client/_app/immutable/nodes/6.BB7QE48r.js.gz +0 -0
  365. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js +0 -2
  366. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.br +0 -0
  367. package/build/client/_app/immutable/nodes/7.D8mqsrZG.js.gz +0 -0
  368. package/build/client/manifest.webmanifest +0 -1
  369. package/build/client/registerSW.js +0 -1
  370. package/build/client/registerSW.js.br +0 -0
  371. package/build/client/registerSW.js.gz +0 -0
  372. package/build/client/sw.js +0 -222
  373. package/build/client/sw.js.br +0 -0
  374. package/build/client/sw.js.gz +0 -0
  375. package/build/client/workbox-5119daf5.js +0 -3395
  376. package/build/client/workbox-5119daf5.js.br +0 -0
  377. package/build/client/workbox-5119daf5.js.gz +0 -0
  378. package/build/server/chunks/0-q2IUp76Y.js +0 -9
  379. package/build/server/chunks/1-CU50G5wZ.js +0 -9
  380. package/build/server/chunks/2-D01t9s8T.js +0 -9
  381. package/build/server/chunks/2-D01t9s8T.js.map +0 -1
  382. package/build/server/chunks/3-5PUQ04wC.js +0 -9
  383. package/build/server/chunks/3-5PUQ04wC.js.map +0 -1
  384. package/build/server/chunks/4-e7gywnSG.js +0 -9
  385. package/build/server/chunks/4-e7gywnSG.js.map +0 -1
  386. package/build/server/chunks/5-CA1SA6KZ.js +0 -9
  387. package/build/server/chunks/5-CA1SA6KZ.js.map +0 -1
  388. package/build/server/chunks/6-71H221sV.js +0 -9
  389. package/build/server/chunks/6-71H221sV.js.map +0 -1
  390. package/build/server/chunks/7-Bo-vmdyz.js +0 -9
  391. package/build/server/chunks/7-Bo-vmdyz.js.map +0 -1
  392. package/build/server/chunks/_layout.svelte-SFHOxs74.js +0 -132
  393. package/build/server/chunks/_layout.svelte-SFHOxs74.js.map +0 -1
  394. package/build/server/chunks/_page.svelte-B4w-2wD-.js +0 -120
  395. package/build/server/chunks/_page.svelte-B4w-2wD-.js.map +0 -1
  396. package/build/server/chunks/_page.svelte-B_qAXjkh.js +0 -213
  397. package/build/server/chunks/_page.svelte-B_qAXjkh.js.map +0 -1
  398. package/build/server/chunks/_page.svelte-CsF1_TRG.js +0 -50
  399. package/build/server/chunks/_page.svelte-CsF1_TRG.js.map +0 -1
  400. package/build/server/chunks/_page.svelte-DJC6U-P0.js +0 -68
  401. package/build/server/chunks/_page.svelte-DJC6U-P0.js.map +0 -1
  402. package/build/server/chunks/_page.svelte-DQ6HBtsz.js +0 -407
  403. package/build/server/chunks/_page.svelte-DQ6HBtsz.js.map +0 -1
  404. package/build/server/chunks/_page.svelte-LbhhjP21.js +0 -148
  405. package/build/server/chunks/_page.svelte-LbhhjP21.js.map +0 -1
  406. package/build/server/chunks/_server.ts-BL2FGb5Z.js.map +0 -1
  407. package/build/server/chunks/_server.ts-BgdjBZco.js.map +0 -1
  408. package/build/server/chunks/_server.ts-BihKSdj_.js.map +0 -1
  409. package/build/server/chunks/_server.ts-BjOJsoy4.js.map +0 -1
  410. package/build/server/chunks/_server.ts-C29xzfaw.js.map +0 -1
  411. package/build/server/chunks/_server.ts-CPa6DgIt.js.map +0 -1
  412. package/build/server/chunks/_server.ts-CbDRDIoP.js.map +0 -1
  413. package/build/server/chunks/_server.ts-Cl1OEWL4.js +0 -54
  414. package/build/server/chunks/_server.ts-Cl1OEWL4.js.map +0 -1
  415. package/build/server/chunks/_server.ts-ColfDHW8.js.map +0 -1
  416. package/build/server/chunks/_server.ts-Cv_OrRuL.js.map +0 -1
  417. package/build/server/chunks/_server.ts-DRVbgm6k.js.map +0 -1
  418. package/build/server/chunks/_server.ts-DfajWaqh.js.map +0 -1
  419. package/build/server/chunks/client-CxCatAKr.js +0 -255
  420. package/build/server/chunks/client-CxCatAKr.js.map +0 -1
  421. package/build/server/chunks/error.svelte-BqdwMWdK.js +0 -26
  422. package/build/server/chunks/error.svelte-BqdwMWdK.js.map +0 -1
  423. package/build/server/chunks/exports-CJ0Q5XmL.js.map +0 -1
  424. package/build/server/chunks/index2-DAxIoAO-.js +0 -36
  425. package/build/server/chunks/index2-DAxIoAO-.js.map +0 -1
  426. package/build/server/chunks/jsonl-parser-dmZU_Hyu.js +0 -137
  427. package/build/server/chunks/jsonl-parser-dmZU_Hyu.js.map +0 -1
  428. package/build/server/chunks/library-apns-BHxLmuIx.js.map +0 -1
  429. package/build/server/chunks/markdown-Bxrl3cCF.js +0 -1241
  430. package/build/server/chunks/markdown-Bxrl3cCF.js.map +0 -1
  431. package/build/server/chunks/pty-manager-C0FhBiVq.js.map +0 -1
  432. package/build/server/chunks/stores-D0HorpgL.js +0 -36
  433. package/build/server/chunks/stores-D0HorpgL.js.map +0 -1
@@ -11,6 +11,13 @@
11
11
  * mode to avoid contention.
12
12
  */
13
13
 
14
+ import type {
15
+ OpenCodeMessage,
16
+ OpenCodePart,
17
+ OpenCodePartData,
18
+ OpenCodeSession,
19
+ } from '$generated/types';
20
+
14
21
  import Database from 'better-sqlite3';
15
22
  import * as fs from 'fs';
16
23
  import * as path from 'path';
@@ -20,11 +27,17 @@ import type { ConversationMessage, MessagePart } from '../sessions/types';
20
27
  // ── Constants ────────────────────────────────────────────────────────
21
28
 
22
29
  const OPENCODE_DB_PATH = (() => {
23
- if (process.platform === 'darwin') {
24
- return path.join(process.env.HOME || '', 'Library', 'Application Support', 'opencode', 'opencode.db');
25
- }
26
- const xdgData = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
27
- return path.join(xdgData, 'opencode', 'opencode.db');
30
+ if (process.platform === 'darwin') {
31
+ return path.join(
32
+ process.env.HOME || '',
33
+ 'Library',
34
+ 'Application Support',
35
+ 'opencode',
36
+ 'opencode.db'
37
+ );
38
+ }
39
+ const xdgData = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
40
+ return path.join(xdgData, 'opencode', 'opencode.db');
28
41
  })();
29
42
 
30
43
  /** Poll interval in milliseconds. */
@@ -33,621 +46,610 @@ const POLL_INTERVAL_MS = 2000;
33
46
  /** Maximum parameters per SQLite IN clause (SQLite limit is 999). */
34
47
  const SQLITE_MAX_PARAMS = 500;
35
48
 
36
- /**
37
- * Normalise a timestamp from OpenCode's SQLite database to milliseconds.
38
- *
39
- * OpenCode currently stores `time_created` / `time_updated` as Unix
40
- * **milliseconds**, but this is not formally documented and could change.
41
- * A simple heuristic distinguishes seconds from milliseconds:
42
- * - Values < 1e12 (~2001-09-09 in ms, ~33658 AD in seconds) are seconds.
43
- * - Values >= 1e12 are already milliseconds.
44
- */
45
- function toMillis(timestamp: number): number {
46
- return timestamp < 1e12 ? timestamp * 1000 : timestamp;
47
- }
48
-
49
- // ── SQLite Row Types ─────────────────────────────────────────────────
50
-
51
- interface OpenCodeMessage {
52
- data: string; // JSON
53
- id: string;
54
- session_id: string;
55
- time_created: number;
56
- time_updated: number;
57
- }
58
-
59
- interface OpenCodePart {
60
- data: string; // JSON
61
- id: string;
62
- message_id: string;
63
- session_id: string;
64
- time_created: number;
65
- time_updated: number;
66
- }
67
-
68
- /** Raw part data as stored in OpenCode's SQLite `part.data` JSON column. */
69
- interface OpenCodePartData {
70
- callID?: string;
71
- id?: string;
72
- state?: { input?: Record<string, unknown> };
73
- text?: string;
74
- tool?: string;
75
- type: string;
76
- }
77
-
78
49
  // ── Per-session Watcher State ────────────────────────────────────────
79
-
80
- interface OpenCodeSession {
81
- directory: string;
82
- id: string;
83
- time_created: number;
84
- time_updated: number;
85
- }
50
+ // WatchState is a runtime/behavioral type (callbacks, emittedSets,
51
+ // intervalHandle) and stays local.
86
52
 
87
53
  interface WatchState {
88
- callbacks: Set<(messages: ConversationMessage[]) => void>;
89
- /** Set of message IDs we have already emitted, to avoid duplicates. */
90
- emittedMessageIds: Set<string>;
91
- /** Set of part IDs we have already emitted, to avoid duplicates on update. */
92
- emittedPartIds: Set<string>;
93
- intervalHandle: ReturnType<typeof setInterval>;
94
- /** Highest time_created we have seen for messages (milliseconds). */
95
- lastMessageTime: number;
96
- /** Highest time_updated we have seen for parts (milliseconds). */
97
- lastPartTime: number;
98
- sessionId: string;
54
+ callbacks: Set<(messages: ConversationMessage[]) => void>;
55
+ /** Set of message IDs we have already emitted, to avoid duplicates. */
56
+ emittedMessageIds: Set<string>;
57
+ /** Map of part ID to last-seen time_updated, to detect in-place updates. */
58
+ emittedPartUpdatedAt: Map<string, number>;
59
+ intervalHandle: ReturnType<typeof setInterval>;
60
+ /** Highest time_created we have seen for messages (milliseconds). */
61
+ lastMessageTime: number;
62
+ /** Highest time_updated we have seen for parts (milliseconds). */
63
+ lastPartTime: number;
64
+ sessionId: string;
99
65
  }
100
66
 
101
- // ── OpenCodeWatcher Class ────────────────────────────────────────────
102
-
103
67
  class OpenCodeWatcher {
104
- private watchers = new Map<string, WatchState>();
105
-
106
- /**
107
- * Find the most recent non-archived OpenCode session that matches
108
- * the given working directory. Checks session.directory equals or
109
- * starts with `cwd`.
110
- *
111
- * Returns the session ID or null if none found.
112
- */
113
- findSessionId(cwd: string, createdAfter?: number): null | string {
114
- const db = openDb();
115
- if (!db) {
116
- return null;
117
- }
118
-
119
- try {
120
- // Match sessions that were active (updated) after the terminal was launched.
121
- // OpenCode resumes existing sessions rather than always creating new ones,
122
- // so we filter on time_updated (not time_created) to find the session
123
- // that's being actively used by this terminal instance.
124
- //
125
- // createdAfter is JS milliseconds (Date.getTime()), but OpenCode may
126
- // store time_updated in seconds. Use the smaller of the two
127
- // representations so the filter works regardless of DB unit.
128
- const timeFilter = createdAfter
129
- ? Math.min(createdAfter, Math.floor(createdAfter / 1000))
130
- : 0;
131
- const row = db
132
- .prepare(
133
- `
68
+ private watchers = new Map<string, WatchState>();
69
+
70
+ /**
71
+ * Find the most recent non-archived OpenCode session that matches
72
+ * the given working directory. Checks session.directory equals or
73
+ * starts with `cwd`.
74
+ *
75
+ * Returns the session ID or null if none found.
76
+ */
77
+ findSessionId(cwd: string, createdAfter?: number): null | string {
78
+ const db = openDb();
79
+ if (!db) {
80
+ return null;
81
+ }
82
+
83
+ try {
84
+ // Match sessions that were active (updated) after the terminal was launched.
85
+ // OpenCode resumes existing sessions rather than always creating new ones,
86
+ // so we filter on time_updated (not time_created) to find the session
87
+ // that's being actively used by this terminal instance.
88
+ //
89
+ // createdAfter is JS milliseconds (Date.getTime()), but OpenCode may
90
+ // store time_updated in either seconds or milliseconds. Provide both
91
+ // representations and let the SQL match whichever unit the DB uses:
92
+ // - If time_updated is in seconds: compare against secondsFilter
93
+ // - If time_updated is in milliseconds (>= 1e12): compare against millisFilter
94
+ const millisFilter = createdAfter ?? 0;
95
+ const secondsFilter = createdAfter ? Math.floor(createdAfter / 1000) : 0;
96
+ // Escape LIKE metacharacters (%, _) in the cwd to prevent false
97
+ // matches on paths containing those characters. Uses \ as the
98
+ // escape character declared via the ESCAPE clause.
99
+ const cwdLikePattern = `${cwd.replace(/[%_\\]/g, '\\$&')}/%`;
100
+ const row = db
101
+ .prepare(
102
+ `
134
103
  SELECT id
135
104
  FROM session
136
105
  WHERE (time_archived IS NULL OR time_archived = 0)
137
- AND (directory = ? OR directory LIKE ? || '/%')
138
- AND time_updated > ?
106
+ AND (directory = ? OR directory LIKE ? ESCAPE '\\')
107
+ AND (
108
+ (time_updated >= 1e12 AND time_updated > ?)
109
+ OR (time_updated < 1e12 AND time_updated > ?)
110
+ )
139
111
  ORDER BY time_updated DESC
140
112
  LIMIT 1
141
113
  `
142
- )
143
- .get(cwd, cwd, timeFilter) as OpenCodeSession | undefined;
144
-
145
- return row?.id ?? null;
146
- } catch (error) {
147
- console.error('[opencode-watcher] Failed to find session:', error);
148
- return null;
149
- } finally {
150
- db.close();
151
- }
152
- }
153
-
154
- /**
155
- * Read all messages and parts for a session from SQLite, converting
156
- * them to ConversationMessage format.
157
- */
158
- getHistory(sessionId: string): ConversationMessage[] {
159
- const db = openDb();
160
- if (!db) {
161
- return [];
162
- }
163
-
164
- try {
165
- // Fetch all messages for this session, ordered chronologically.
166
- const messages = db
167
- .prepare(
168
- `
114
+ )
115
+ .get(cwd, cwdLikePattern, millisFilter, secondsFilter) as OpenCodeSession | undefined;
116
+
117
+ return row?.id ?? null;
118
+ } catch (error) {
119
+ console.error('[opencode-watcher] Failed to find session:', error);
120
+ return null;
121
+ } finally {
122
+ db.close();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Read all messages and parts for a session from SQLite, converting
128
+ * them to ConversationMessage format.
129
+ */
130
+ getHistory(sessionId: string): ConversationMessage[] {
131
+ const db = openDb();
132
+ if (!db) {
133
+ return [];
134
+ }
135
+
136
+ try {
137
+ // Fetch all messages for this session, ordered chronologically.
138
+ const messages = db
139
+ .prepare(
140
+ `
169
141
  SELECT id, session_id, time_created, time_updated, data
170
142
  FROM message
171
143
  WHERE session_id = ?
172
144
  ORDER BY time_created ASC
173
145
  `
174
- )
175
- .all(sessionId) as OpenCodeMessage[];
176
-
177
- if (messages.length === 0) {
178
- return [];
179
- }
180
-
181
- // Fetch all parts for these messages (batched to avoid SQLite param limit).
182
- const messageIds = messages.map((m) => m.id);
183
- const parts = batchInQuery<OpenCodePart>(
184
- db,
185
- `SELECT id, message_id, session_id, time_created, time_updated, data
146
+ )
147
+ .all(sessionId) as OpenCodeMessage[];
148
+
149
+ if (messages.length === 0) {
150
+ return [];
151
+ }
152
+
153
+ // Fetch all parts for these messages (batched to avoid SQLite param limit).
154
+ const messageIds = messages.map((m) => m.id);
155
+ const parts = batchInQuery<OpenCodePart>(
156
+ db,
157
+ `SELECT id, message_id, session_id, time_created, time_updated, data
186
158
  FROM part
187
159
  WHERE message_id IN (__PLACEHOLDERS__)
188
160
  ORDER BY time_created ASC`,
189
- messageIds
190
- );
191
-
192
- // Group parts by message ID.
193
- const partsByMessage = new Map<string, OpenCodePart[]>();
194
- for (const part of parts) {
195
- if (!partsByMessage.has(part.message_id)) {
196
- partsByMessage.set(part.message_id, []);
197
- }
198
- partsByMessage.get(part.message_id)!.push(part);
199
- }
200
-
201
- return this.buildMessages(messages, partsByMessage);
202
- } catch (error) {
203
- console.error('[opencode-watcher] Failed to read history:', error);
204
- return [];
205
- } finally {
206
- db.close();
207
- }
208
- }
209
-
210
- /**
211
- * Stop watching a specific session. If a callback is provided, only that
212
- * subscriber is removed — the interval keeps running while other subscribers
213
- * remain. If no callback is provided, all subscribers and the interval are
214
- * cleared (backward compat).
215
- */
216
- stop(sessionId: string, callback?: (messages: ConversationMessage[]) => void): void {
217
- const state = this.watchers.get(sessionId);
218
- if (!state) {
219
- return;
220
- }
221
-
222
- if (callback) {
223
- state.callbacks.delete(callback);
224
- console.log(
225
- `[opencode-watcher] Removed subscriber from session: ${sessionId} ` +
226
- `(remaining=${state.callbacks.size})`
227
- );
228
-
229
- // Only tear down the interval when no subscribers remain.
230
- if (state.callbacks.size > 0) {
231
- return;
232
- }
233
- }
234
-
235
- clearInterval(state.intervalHandle);
236
- this.watchers.delete(sessionId);
237
- console.log(`[opencode-watcher] Stopped watching session: ${sessionId}`);
238
- }
239
-
240
- /**
241
- * Stop all active watchers.
242
- */
243
- stopAll(): void {
244
- for (const [sessionId] of this.watchers) {
245
- this.stop(sessionId);
246
- }
247
- }
248
-
249
- /**
250
- * Start polling the SQLite DB every 2 seconds for new messages/parts
251
- * in the given session. Converts new data to ConversationMessage
252
- * format and invokes the callback.
253
- */
254
- watch(sessionId: string, callback: (messages: ConversationMessage[]) => void): void {
255
- const existing = this.watchers.get(sessionId);
256
- if (existing) {
257
- // Already watching — just add the new callback, don't create a new interval.
258
- existing.callbacks.add(callback);
259
- console.log(
260
- `[opencode-watcher] Added subscriber to session: ${sessionId} ` +
261
- `(total=${existing.callbacks.size})`
262
- );
263
- return;
264
- }
265
-
266
- // Determine the initial high-water marks by scanning existing data.
267
- const { emittedMessageIds, emittedPartIds, lastMessageTime, lastPartTime } =
268
- this.getHighWaterMarks(sessionId);
269
-
270
- const intervalHandle = setInterval(() => {
271
- this.poll(sessionId);
272
- }, POLL_INTERVAL_MS);
273
-
274
- const state: WatchState = {
275
- callbacks: new Set([callback]),
276
- emittedMessageIds,
277
- emittedPartIds,
278
- intervalHandle,
279
- lastMessageTime,
280
- lastPartTime,
281
- sessionId
282
- };
283
-
284
- this.watchers.set(sessionId, state);
285
- console.log(
286
- `[opencode-watcher] Watching session: ${sessionId} ` +
287
- `(lastMsg=${lastMessageTime}, lastPart=${lastPartTime})`
288
- );
289
- }
290
-
291
- // ── Private Helpers ────────────────────────────────────────────────
292
-
293
- /**
294
- * Convert OpenCode messages + their parts into ConversationMessage
295
- * objects for consumption by the session handler.
296
- */
297
- private buildMessages(
298
- messages: OpenCodeMessage[],
299
- partsByMessage: Map<string, OpenCodePart[]>
300
- ): ConversationMessage[] {
301
- const result: ConversationMessage[] = [];
302
-
303
- for (const msg of messages) {
304
- // Parse message data to determine role.
305
- let msgData: { agent?: string; role?: string } = {};
306
- try {
307
- msgData = JSON.parse(msg.data) as typeof msgData;
308
- } catch {
309
- // Skip unparseable message data.
310
- continue;
311
- }
312
-
313
- const role = msgData.role === 'user' ? 'user' : 'assistant';
314
- const msgParts = partsByMessage.get(msg.id) || [];
315
-
316
- // Convert each part to a MessagePart.
317
- const parts: MessagePart[] = [];
318
-
319
- for (const part of msgParts) {
320
- let partData: OpenCodePartData;
321
- try {
322
- partData = JSON.parse(part.data) as OpenCodePartData;
323
- } catch {
324
- continue;
325
- }
326
-
327
- const converted = convertPartToMessagePart(partData);
328
- if (converted) {
329
- parts.push(converted);
330
- }
331
- }
332
-
333
- // Skip messages with no usable content.
334
- if (parts.length === 0) {
335
- console.debug(`[opencode-watcher] Skipping message ${msg.id} (no usable parts)`);
336
- continue;
337
- }
338
-
339
- result.push({
340
- id: msg.id,
341
- parts,
342
- role,
343
- timestamp: new Date(toMillis(msg.time_created)).toISOString(),
344
- });
345
- }
346
-
347
- return result;
348
- }
349
-
350
- /**
351
- * Scan existing messages/parts to determine the starting high-water
352
- * marks for time-based polling. Also collects the set of already-seen
353
- * message IDs so we do not re-emit them.
354
- */
355
- private getHighWaterMarks(sessionId: string): {
356
- emittedMessageIds: Set<string>;
357
- emittedPartIds: Set<string>;
358
- lastMessageTime: number;
359
- lastPartTime: number;
360
- } {
361
- const db = openDb();
362
- if (!db) {
363
- return { emittedMessageIds: new Set(), emittedPartIds: new Set(), lastMessageTime: 0, lastPartTime: 0 };
364
- }
365
-
366
- try {
367
- const msgRow = db
368
- .prepare(
369
- `
161
+ messageIds
162
+ );
163
+
164
+ // Group parts by message ID.
165
+ const partsByMessage = new Map<string, OpenCodePart[]>();
166
+ for (const part of parts) {
167
+ if (!partsByMessage.has(part.message_id)) {
168
+ partsByMessage.set(part.message_id, []);
169
+ }
170
+ partsByMessage.get(part.message_id)!.push(part);
171
+ }
172
+
173
+ return this.buildMessages(messages, partsByMessage);
174
+ } catch (error) {
175
+ console.error('[opencode-watcher] Failed to read history:', error);
176
+ return [];
177
+ } finally {
178
+ db.close();
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Stop watching a specific session. If a callback is provided, only that
184
+ * subscriber is removed — the interval keeps running while other subscribers
185
+ * remain. If no callback is provided, all subscribers and the interval are
186
+ * cleared (backward compat).
187
+ */
188
+ stop(sessionId: string, callback?: (messages: ConversationMessage[]) => void): void {
189
+ const state = this.watchers.get(sessionId);
190
+ if (!state) {
191
+ return;
192
+ }
193
+
194
+ if (callback) {
195
+ state.callbacks.delete(callback);
196
+ console.log(
197
+ `[opencode-watcher] Removed subscriber from session: ${sessionId} ` +
198
+ `(remaining=${state.callbacks.size})`
199
+ );
200
+
201
+ // Only tear down the interval when no subscribers remain.
202
+ if (state.callbacks.size > 0) {
203
+ return;
204
+ }
205
+ }
206
+
207
+ clearInterval(state.intervalHandle);
208
+ this.watchers.delete(sessionId);
209
+ console.log(`[opencode-watcher] Stopped watching session: ${sessionId}`);
210
+ }
211
+
212
+ /**
213
+ * Stop all active watchers.
214
+ */
215
+ stopAll(): void {
216
+ for (const [sessionId] of this.watchers) {
217
+ this.stop(sessionId);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Start polling the SQLite DB every 2 seconds for new messages/parts
223
+ * in the given session. Converts new data to ConversationMessage
224
+ * format and invokes the callback.
225
+ */
226
+ watch(sessionId: string, callback: (messages: ConversationMessage[]) => void): void {
227
+ const existing = this.watchers.get(sessionId);
228
+ if (existing) {
229
+ // Already watching — just add the new callback, don't create a new interval.
230
+ existing.callbacks.add(callback);
231
+ console.log(
232
+ `[opencode-watcher] Added subscriber to session: ${sessionId} ` +
233
+ `(total=${existing.callbacks.size})`
234
+ );
235
+ return;
236
+ }
237
+
238
+ // Determine the initial high-water marks by scanning existing data.
239
+ const { emittedMessageIds, emittedPartUpdatedAt, lastMessageTime, lastPartTime } =
240
+ this.getHighWaterMarks(sessionId);
241
+
242
+ const intervalHandle = setInterval(() => {
243
+ this.poll(sessionId);
244
+ }, POLL_INTERVAL_MS);
245
+
246
+ const state: WatchState = {
247
+ callbacks: new Set([callback]),
248
+ emittedMessageIds,
249
+ emittedPartUpdatedAt,
250
+ intervalHandle,
251
+ lastMessageTime,
252
+ lastPartTime,
253
+ sessionId,
254
+ };
255
+
256
+ this.watchers.set(sessionId, state);
257
+ console.log(
258
+ `[opencode-watcher] Watching session: ${sessionId} ` +
259
+ `(lastMsg=${lastMessageTime}, lastPart=${lastPartTime})`
260
+ );
261
+ }
262
+
263
+ // ── Private Helpers ────────────────────────────────────────────────
264
+
265
+ /**
266
+ * Convert OpenCode messages + their parts into ConversationMessage
267
+ * objects for consumption by the session handler.
268
+ */
269
+ private buildMessages(
270
+ messages: OpenCodeMessage[],
271
+ partsByMessage: Map<string, OpenCodePart[]>
272
+ ): ConversationMessage[] {
273
+ const result: ConversationMessage[] = [];
274
+
275
+ for (const msg of messages) {
276
+ // Parse message data to determine role.
277
+ let msgData: { agent?: string; role?: string } = {};
278
+ try {
279
+ msgData = JSON.parse(msg.data) as typeof msgData;
280
+ } catch {
281
+ // Skip unparseable message data.
282
+ continue;
283
+ }
284
+
285
+ const role = msgData.role === 'user' ? 'user' : 'assistant';
286
+ const msgParts = partsByMessage.get(msg.id) || [];
287
+
288
+ // Convert each part to a MessagePart.
289
+ const parts: MessagePart[] = [];
290
+
291
+ for (const part of msgParts) {
292
+ let partData: OpenCodePartData;
293
+ try {
294
+ partData = JSON.parse(part.data) as OpenCodePartData;
295
+ } catch {
296
+ continue;
297
+ }
298
+
299
+ const converted = convertPartToMessagePart(partData);
300
+ if (converted) {
301
+ parts.push(converted);
302
+ }
303
+ }
304
+
305
+ // Skip messages with no usable content.
306
+ if (parts.length === 0) {
307
+ console.debug(`[opencode-watcher] Skipping message ${msg.id} (no usable parts)`);
308
+ continue;
309
+ }
310
+
311
+ result.push({
312
+ id: msg.id,
313
+ parts,
314
+ role,
315
+ timestamp: new Date(toMillis(msg.time_created)).toISOString(),
316
+ });
317
+ }
318
+
319
+ return result;
320
+ }
321
+
322
+ /**
323
+ * Scan existing messages/parts to determine the starting high-water
324
+ * marks for time-based polling. Also collects the set of already-seen
325
+ * message IDs so we do not re-emit them.
326
+ */
327
+ private getHighWaterMarks(sessionId: string): {
328
+ emittedMessageIds: Set<string>;
329
+ emittedPartUpdatedAt: Map<string, number>;
330
+ lastMessageTime: number;
331
+ lastPartTime: number;
332
+ } {
333
+ const db = openDb();
334
+ if (!db) {
335
+ return {
336
+ emittedMessageIds: new Set(),
337
+ emittedPartUpdatedAt: new Map(),
338
+ lastMessageTime: 0,
339
+ lastPartTime: 0,
340
+ };
341
+ }
342
+
343
+ try {
344
+ const msgRow = db
345
+ .prepare(
346
+ `
370
347
  SELECT MAX(time_created) as maxTime
371
348
  FROM message
372
349
  WHERE session_id = ?
373
350
  `
374
- )
375
- .get(sessionId) as undefined | { maxTime: null | number };
351
+ )
352
+ .get(sessionId) as undefined | { maxTime: null | number };
376
353
 
377
- const partRow = db
378
- .prepare(
379
- `
354
+ const partRow = db
355
+ .prepare(
356
+ `
380
357
  SELECT MAX(time_updated) as maxTime
381
358
  FROM part
382
359
  WHERE session_id = ?
383
360
  `
384
- )
385
- .get(sessionId) as undefined | { maxTime: null | number };
361
+ )
362
+ .get(sessionId) as undefined | { maxTime: null | number };
386
363
 
387
- // Collect all existing message IDs.
388
- const existingIds = db
389
- .prepare(
390
- `
364
+ // Collect all existing message IDs.
365
+ const existingIds = db
366
+ .prepare(
367
+ `
391
368
  SELECT id FROM message WHERE session_id = ?
392
369
  `
393
- )
394
- .all(sessionId) as { id: string }[];
370
+ )
371
+ .all(sessionId) as { id: string }[];
395
372
 
396
- const emittedMessageIds = new Set(existingIds.map((r) => r.id));
373
+ const emittedMessageIds = new Set(existingIds.map((r) => r.id));
397
374
 
398
- // Collect all existing part IDs.
399
- const existingPartIds = db
400
- .prepare(
401
- `
402
- SELECT id FROM part WHERE session_id = ?
375
+ // Collect all existing part IDs with their time_updated for change detection.
376
+ const existingParts = db
377
+ .prepare(
378
+ `
379
+ SELECT id, time_updated FROM part WHERE session_id = ?
403
380
  `
404
- )
405
- .all(sessionId) as { id: string }[];
406
-
407
- const emittedPartIds = new Set(existingPartIds.map((r) => r.id));
408
-
409
- return {
410
- emittedMessageIds,
411
- emittedPartIds,
412
- lastMessageTime: msgRow?.maxTime ?? 0,
413
- lastPartTime: partRow?.maxTime ?? 0
414
- };
415
- } catch (error) {
416
- console.error('[opencode-watcher] Failed to get high-water marks:', error);
417
- return { emittedMessageIds: new Set(), emittedPartIds: new Set(), lastMessageTime: 0, lastPartTime: 0 };
418
- } finally {
419
- db.close();
420
- }
421
- }
422
-
423
- /**
424
- * Single poll iteration. Opens the DB, queries for new messages and
425
- * updated parts, converts them to ConversationMessage[], and invokes
426
- * the watcher callbacks.
427
- */
428
- private poll(sessionId: string): void {
429
- const state = this.watchers.get(sessionId);
430
- if (!state) {
431
- return;
432
- }
433
-
434
- const db = openDb();
435
- if (!db) {
436
- return;
437
- }
438
-
439
- try {
440
- const results: ConversationMessage[] = [];
441
-
442
- // ── 1. Check for brand-new messages ──────────────────────────
443
- const newMessages = db
444
- .prepare(
445
- `
381
+ )
382
+ .all(sessionId) as { id: string; time_updated: number }[];
383
+
384
+ const emittedPartUpdatedAt = new Map<string, number>();
385
+ for (const p of existingParts) {
386
+ emittedPartUpdatedAt.set(p.id, p.time_updated);
387
+ }
388
+
389
+ return {
390
+ emittedMessageIds,
391
+ emittedPartUpdatedAt,
392
+ lastMessageTime: msgRow?.maxTime ?? 0,
393
+ lastPartTime: partRow?.maxTime ?? 0,
394
+ };
395
+ } catch (error) {
396
+ console.error('[opencode-watcher] Failed to get high-water marks:', error);
397
+ return {
398
+ emittedMessageIds: new Set(),
399
+ emittedPartUpdatedAt: new Map(),
400
+ lastMessageTime: 0,
401
+ lastPartTime: 0,
402
+ };
403
+ } finally {
404
+ db.close();
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Single poll iteration. Opens the DB, queries for new messages and
410
+ * updated parts, converts them to ConversationMessage[], and invokes
411
+ * the watcher callbacks.
412
+ */
413
+ private poll(sessionId: string): void {
414
+ const state = this.watchers.get(sessionId);
415
+ if (!state) {
416
+ return;
417
+ }
418
+
419
+ const db = openDb();
420
+ if (!db) {
421
+ return;
422
+ }
423
+
424
+ try {
425
+ const results: ConversationMessage[] = [];
426
+
427
+ // ── 1. Check for brand-new messages ──────────────────────────
428
+ const newMessages = db
429
+ .prepare(
430
+ `
446
431
  SELECT id, session_id, time_created, time_updated, data
447
432
  FROM message
448
433
  WHERE session_id = ? AND time_created > ?
449
434
  ORDER BY time_created ASC
450
435
  `
451
- )
452
- .all(sessionId, state.lastMessageTime) as OpenCodeMessage[];
453
-
454
- if (newMessages.length > 0) {
455
- // Deduplicate: skip messages we have already emitted (guards
456
- // against clock-tie edge cases where time_created equals the
457
- // high-water mark and the row slips through the > filter again).
458
- const dedupedMessages = newMessages.filter(
459
- (m) => !state.emittedMessageIds.has(m.id)
460
- );
461
-
462
- if (dedupedMessages.length > 0) {
463
- // Fetch parts for these new messages (batched to avoid SQLite param limit).
464
- const newMsgIds = dedupedMessages.map((m) => m.id);
465
- const newParts = batchInQuery<OpenCodePart>(
466
- db,
467
- `SELECT id, message_id, session_id, time_created, time_updated, data
436
+ )
437
+ .all(sessionId, state.lastMessageTime) as OpenCodeMessage[];
438
+
439
+ if (newMessages.length > 0) {
440
+ // Deduplicate: skip messages we have already emitted (guards
441
+ // against clock-tie edge cases where time_created equals the
442
+ // high-water mark and the row slips through the > filter again).
443
+ const dedupedMessages = newMessages.filter((m) => !state.emittedMessageIds.has(m.id));
444
+
445
+ if (dedupedMessages.length > 0) {
446
+ // Fetch parts for these new messages (batched to avoid SQLite param limit).
447
+ const newMsgIds = dedupedMessages.map((m) => m.id);
448
+ const newParts = batchInQuery<OpenCodePart>(
449
+ db,
450
+ `SELECT id, message_id, session_id, time_created, time_updated, data
468
451
  FROM part
469
452
  WHERE message_id IN (__PLACEHOLDERS__)
470
453
  ORDER BY time_created ASC`,
471
- newMsgIds
472
- );
473
-
474
- // Group parts by message.
475
- const partsByMessage = new Map<string, OpenCodePart[]>();
476
- for (const part of newParts) {
477
- if (!partsByMessage.has(part.message_id)) {
478
- partsByMessage.set(part.message_id, []);
479
- }
480
- partsByMessage.get(part.message_id)!.push(part);
481
- }
482
-
483
- const newEntries = this.buildMessages(dedupedMessages, partsByMessage);
484
- results.push(...newEntries);
485
-
486
- // Track emitted part IDs and update part high-water mark.
487
- for (const part of newParts) {
488
- if (part.time_updated > state.lastPartTime) {
489
- state.lastPartTime = part.time_updated;
490
- }
491
- state.emittedPartIds.add(part.id);
492
- }
493
- }
494
-
495
- // Always update message high-water marks (even for already-emitted messages).
496
- for (const msg of newMessages) {
497
- if (msg.time_created > state.lastMessageTime) {
498
- state.lastMessageTime = msg.time_created;
499
- }
500
- state.emittedMessageIds.add(msg.id);
501
- }
502
- }
503
-
504
- // ── 2. Check for updated parts on existing messages ──────────
505
- // Parts may be added or updated after the initial message row is
506
- // created (e.g., streaming assistant response). Look for parts
507
- // whose time_updated exceeds our last-seen mark, but whose parent
508
- // message is NOT in the new-messages set (those were handled above).
509
- const updatedParts = db
510
- .prepare(
511
- `
454
+ newMsgIds
455
+ );
456
+
457
+ // Group parts by message.
458
+ const partsByMessage = new Map<string, OpenCodePart[]>();
459
+ for (const part of newParts) {
460
+ if (!partsByMessage.has(part.message_id)) {
461
+ partsByMessage.set(part.message_id, []);
462
+ }
463
+ partsByMessage.get(part.message_id)!.push(part);
464
+ }
465
+
466
+ const newEntries = this.buildMessages(dedupedMessages, partsByMessage);
467
+ results.push(...newEntries);
468
+
469
+ // Track emitted part timestamps and update part high-water mark.
470
+ for (const part of newParts) {
471
+ if (part.time_updated > state.lastPartTime) {
472
+ state.lastPartTime = part.time_updated;
473
+ }
474
+ state.emittedPartUpdatedAt.set(part.id, part.time_updated);
475
+ }
476
+ }
477
+
478
+ // Always update message high-water marks (even for already-emitted messages).
479
+ for (const msg of newMessages) {
480
+ if (msg.time_created > state.lastMessageTime) {
481
+ state.lastMessageTime = msg.time_created;
482
+ }
483
+ state.emittedMessageIds.add(msg.id);
484
+ }
485
+ }
486
+
487
+ // ── 2. Check for updated parts on existing messages ──────────
488
+ // Parts may be added or updated after the initial message row is
489
+ // created (e.g., streaming assistant response). Look for parts
490
+ // whose time_updated exceeds our last-seen mark, but whose parent
491
+ // message is NOT in the new-messages set (those were handled above).
492
+ const updatedParts = db
493
+ .prepare(
494
+ `
512
495
  SELECT id, message_id, session_id, time_created, time_updated, data
513
496
  FROM part
514
497
  WHERE session_id = ? AND time_updated > ?
515
498
  ORDER BY time_created ASC
516
499
  `
517
- )
518
- .all(sessionId, state.lastPartTime) as OpenCodePart[];
519
-
520
- if (updatedParts.length > 0) {
521
- // Filter out parts that belong to messages we just processed,
522
- // AND parts we have already emitted (avoid duplicates).
523
- const newMsgIdSet = new Set(newMessages.map((m) => m.id));
524
- const newParts = updatedParts.filter(
525
- (p) => !newMsgIdSet.has(p.message_id) && !state.emittedPartIds.has(p.id)
526
- );
527
-
528
- if (newParts.length > 0) {
529
- // Group the NEW parts by message ID.
530
- const partsByMessage = new Map<string, OpenCodePart[]>();
531
- for (const part of newParts) {
532
- if (!partsByMessage.has(part.message_id)) {
533
- partsByMessage.set(part.message_id, []);
534
- }
535
- partsByMessage.get(part.message_id)!.push(part);
536
- }
537
-
538
- // Fetch the parent messages for context (batched to avoid SQLite param limit).
539
- const affectedMsgIds = [...partsByMessage.keys()];
540
- const affectedMessages = batchInQuery<OpenCodeMessage>(
541
- db,
542
- `SELECT id, session_id, time_created, time_updated, data
500
+ )
501
+ .all(sessionId, state.lastPartTime) as OpenCodePart[];
502
+
503
+ if (updatedParts.length > 0) {
504
+ // Filter out parts that belong to messages we just processed.
505
+ // For previously-emitted parts, allow re-emission only if
506
+ // time_updated has increased (in-place update detection).
507
+ const newMsgIdSet = new Set(newMessages.map((m) => m.id));
508
+ const newParts = updatedParts.filter((p) => {
509
+ if (newMsgIdSet.has(p.message_id)) {
510
+ return false;
511
+ }
512
+ const lastSeen = state.emittedPartUpdatedAt.get(p.id);
513
+ // New part (never emitted) or updated since last emission
514
+ return lastSeen === undefined || p.time_updated > lastSeen;
515
+ });
516
+
517
+ if (newParts.length > 0) {
518
+ // Group the NEW parts by message ID.
519
+ const partsByMessage = new Map<string, OpenCodePart[]>();
520
+ for (const part of newParts) {
521
+ if (!partsByMessage.has(part.message_id)) {
522
+ partsByMessage.set(part.message_id, []);
523
+ }
524
+ partsByMessage.get(part.message_id)!.push(part);
525
+ }
526
+
527
+ // Fetch the parent messages for context (batched to avoid SQLite param limit).
528
+ const affectedMsgIds = [...partsByMessage.keys()];
529
+ const affectedMessages = batchInQuery<OpenCodeMessage>(
530
+ db,
531
+ `SELECT id, session_id, time_created, time_updated, data
543
532
  FROM message
544
533
  WHERE id IN (__PLACEHOLDERS__)
545
534
  ORDER BY time_created ASC`,
546
- affectedMsgIds
547
- );
548
-
549
- // Build entries from ONLY the new parts (delta), not all
550
- // parts for the message. This prevents re-emitting content
551
- // the session handler has already seen.
552
- const updatedEntries = this.buildMessages(affectedMessages, partsByMessage);
553
- results.push(...updatedEntries);
554
-
555
- // Track emitted part IDs.
556
- for (const part of newParts) {
557
- state.emittedPartIds.add(part.id);
558
- }
559
- }
560
-
561
- // Update part high-water mark.
562
- for (const part of updatedParts) {
563
- if (part.time_updated > state.lastPartTime) {
564
- state.lastPartTime = part.time_updated;
565
- }
566
- }
567
- }
568
-
569
- // ── 3. Invoke all callbacks if there are new entries ─────────
570
- if (results.length > 0) {
571
- for (const cb of state.callbacks) {
572
- try {
573
- cb(results);
574
- } catch (cbError) {
575
- console.error('[opencode-watcher] Callback error:', cbError);
576
- }
577
- }
578
- }
579
- } catch (error) {
580
- // Log but do not crash — the next poll will retry.
581
- console.error('[opencode-watcher] Poll error:', error);
582
- } finally {
583
- db.close();
584
- }
585
- }
535
+ affectedMsgIds
536
+ );
537
+
538
+ // Build entries from ONLY the new parts (delta), not all
539
+ // parts for the message. This prevents re-emitting content
540
+ // the session handler has already seen.
541
+ const updatedEntries = this.buildMessages(affectedMessages, partsByMessage);
542
+ results.push(...updatedEntries);
543
+
544
+ // Track emitted part timestamps for change detection.
545
+ for (const part of newParts) {
546
+ state.emittedPartUpdatedAt.set(part.id, part.time_updated);
547
+ }
548
+ }
549
+
550
+ // Update part high-water mark.
551
+ for (const part of updatedParts) {
552
+ if (part.time_updated > state.lastPartTime) {
553
+ state.lastPartTime = part.time_updated;
554
+ }
555
+ }
556
+ }
557
+
558
+ // ── 3. Invoke all callbacks if there are new entries ─────────
559
+ if (results.length > 0) {
560
+ for (const cb of state.callbacks) {
561
+ try {
562
+ cb(results);
563
+ } catch (cbError) {
564
+ console.error('[opencode-watcher] Callback error:', cbError);
565
+ }
566
+ }
567
+ }
568
+ } catch (error) {
569
+ // Log but do not crash — the next poll will retry.
570
+ console.error('[opencode-watcher] Poll error:', error);
571
+ } finally {
572
+ db.close();
573
+ }
574
+ }
586
575
  }
587
576
 
588
- // ── Database Helpers ─────────────────────────────────────────────────
577
+ // ── OpenCodeWatcher Class ────────────────────────────────────────────
589
578
 
590
579
  /**
591
580
  * Execute a SELECT query with an IN clause, batching in chunks of
592
581
  * SQLITE_MAX_PARAMS to stay within SQLite's 999-parameter limit.
593
582
  */
594
- function batchInQuery<T>(
595
- db: Database.Database,
596
- sql: string,
597
- ids: string[]
598
- ): T[] {
599
- if (ids.length === 0) return [];
600
-
601
- const results: T[] = [];
602
- for (let i = 0; i < ids.length; i += SQLITE_MAX_PARAMS) {
603
- const chunk = ids.slice(i, i + SQLITE_MAX_PARAMS);
604
- const placeholders = chunk.map(() => '?').join(',');
605
- const query = sql.replace('__PLACEHOLDERS__', placeholders);
606
- const rows = db.prepare(query).all(...chunk) as T[];
607
- results.push(...rows);
608
- }
609
- return results;
583
+ function batchInQuery<T>(db: Database.Database, sql: string, ids: string[]): T[] {
584
+ if (ids.length === 0) {
585
+ return [];
586
+ }
587
+
588
+ const results: T[] = [];
589
+ for (let i = 0; i < ids.length; i += SQLITE_MAX_PARAMS) {
590
+ const chunk = ids.slice(i, i + SQLITE_MAX_PARAMS);
591
+ const placeholders = chunk.map(() => '?').join(',');
592
+ const query = sql.replace('__PLACEHOLDERS__', placeholders);
593
+ const rows = db.prepare(query).all(...chunk) as T[];
594
+ results.push(...rows);
595
+ }
596
+ return results;
610
597
  }
611
598
 
612
- // ── Part Conversion ──────────────────────────────────────────────────
613
- // Maps OpenCode part types to MessagePart directly, skipping the
614
- // intermediate Record<string, unknown> stage.
599
+ // ── Database Helpers ─────────────────────────────────────────────────
615
600
 
616
601
  function convertPartToMessagePart(data: OpenCodePartData): MessagePart | null {
617
- switch (data.type) {
618
- case 'reasoning':
619
- return { content: data.text || '', type: 'thinking' };
620
-
621
- case 'text':
622
- return { content: data.text || '', type: 'text' };
623
-
624
- case 'tool':
625
- return {
626
- id: data.callID || data.id || '',
627
- input: data.state?.input || {},
628
- toolName: data.tool || 'Unknown',
629
- type: 'tool_use'
630
- };
631
-
632
- default:
633
- // Skip snapshot, patch, step-start, step-finish, subtask, retry, compaction
634
- return null;
635
- }
602
+ switch (data.type) {
603
+ case 'reasoning':
604
+ return { content: data.text || '', type: 'thinking' };
605
+
606
+ case 'text':
607
+ return { content: data.text || '', type: 'text' };
608
+
609
+ case 'tool':
610
+ return {
611
+ id: data.callID || data.id || '',
612
+ input: data.state?.input || {},
613
+ toolName: data.tool || 'Unknown',
614
+ type: 'tool_use',
615
+ };
616
+
617
+ default:
618
+ // Skip snapshot, patch, step-start, step-finish, subtask, retry, compaction
619
+ return null;
620
+ }
636
621
  }
637
622
 
623
+ // ── Part Conversion ──────────────────────────────────────────────────
624
+ // Maps OpenCode part types to MessagePart directly, skipping the
625
+ // intermediate Record<string, unknown> stage.
626
+
638
627
  /**
639
628
  * Open the OpenCode SQLite database in read-only mode.
640
629
  * Returns null if the file does not exist or cannot be opened.
641
630
  */
642
631
  function openDb(): Database.Database | null {
643
- if (!fs.existsSync(OPENCODE_DB_PATH)) {
644
- return null;
645
- }
646
- try {
647
- return new Database(OPENCODE_DB_PATH, { readonly: true });
648
- } catch {
649
- return null;
650
- }
632
+ if (!fs.existsSync(OPENCODE_DB_PATH)) {
633
+ return null;
634
+ }
635
+ try {
636
+ return new Database(OPENCODE_DB_PATH, { readonly: true });
637
+ } catch {
638
+ return null;
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Normalise a timestamp from OpenCode's SQLite database to milliseconds.
644
+ *
645
+ * OpenCode currently stores `time_created` / `time_updated` as Unix
646
+ * **milliseconds**, but this is not formally documented and could change.
647
+ * A simple heuristic distinguishes seconds from milliseconds:
648
+ * - Values < 1e12 (~2001-09-09 in ms, ~33658 AD in seconds) are seconds.
649
+ * - Values >= 1e12 are already milliseconds.
650
+ */
651
+ function toMillis(timestamp: number): number {
652
+ return timestamp < 1e12 ? timestamp * 1000 : timestamp;
651
653
  }
652
654
 
653
655
  // ── Singleton ────────────────────────────────────────────────────────
@@ -656,6 +658,6 @@ function openDb(): Database.Database | null {
656
658
 
657
659
  const OW_GLOBAL_KEY = '__shooter_opencode_watcher';
658
660
  export const openCodeWatcher: OpenCodeWatcher =
659
- ((globalThis as Record<string, unknown>)[OW_GLOBAL_KEY] as OpenCodeWatcher) ||
660
- new OpenCodeWatcher();
661
+ ((globalThis as Record<string, unknown>)[OW_GLOBAL_KEY] as OpenCodeWatcher) ||
662
+ new OpenCodeWatcher();
661
663
  (globalThis as Record<string, unknown>)[OW_GLOBAL_KEY] = openCodeWatcher;