@juspay/shooter 1.21.0 → 1.23.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 (221) hide show
  1. package/.claude/hooks/notifier.cjs +94 -1
  2. package/build/client/_app/immutable/assets/2.JWRrnR-w.css +1 -0
  3. package/build/client/_app/immutable/assets/2.JWRrnR-w.css.br +0 -0
  4. package/build/client/_app/immutable/assets/2.JWRrnR-w.css.gz +0 -0
  5. package/build/client/_app/immutable/chunks/{DOEXXmsh.js → Bj5wFimK.js} +2 -2
  6. package/build/client/_app/immutable/chunks/Bj5wFimK.js.br +0 -0
  7. package/build/client/_app/immutable/chunks/Bj5wFimK.js.gz +0 -0
  8. package/build/client/_app/immutable/chunks/{EqMAkEha.js → BjYr_-Ss.js} +1 -1
  9. package/build/client/_app/immutable/chunks/BjYr_-Ss.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/BjYr_-Ss.js.gz +0 -0
  11. package/build/client/_app/immutable/chunks/C4Hns_Wl.js +1 -0
  12. package/build/client/_app/immutable/chunks/C4Hns_Wl.js.br +0 -0
  13. package/build/client/_app/immutable/chunks/C4Hns_Wl.js.gz +0 -0
  14. package/build/client/_app/immutable/chunks/DULfdsh6.js +6 -0
  15. package/build/client/_app/immutable/chunks/DULfdsh6.js.br +0 -0
  16. package/build/client/_app/immutable/chunks/DULfdsh6.js.gz +0 -0
  17. package/build/client/_app/immutable/chunks/{BmfLecb1.js → fcNfTA-E.js} +1 -1
  18. package/build/client/_app/immutable/chunks/fcNfTA-E.js.br +0 -0
  19. package/build/client/_app/immutable/chunks/fcNfTA-E.js.gz +0 -0
  20. package/build/client/_app/immutable/entry/{app.CeSxgGat.js → app.Bvoqymnp.js} +2 -2
  21. package/build/client/_app/immutable/entry/app.Bvoqymnp.js.br +0 -0
  22. package/build/client/_app/immutable/entry/app.Bvoqymnp.js.gz +0 -0
  23. package/build/client/_app/immutable/entry/start.BqXCPPZJ.js +1 -0
  24. package/build/client/_app/immutable/entry/start.BqXCPPZJ.js.br +2 -0
  25. package/build/client/_app/immutable/entry/start.BqXCPPZJ.js.gz +0 -0
  26. package/build/client/_app/immutable/nodes/{0.oaPwxh1O.js → 0.Bv_TwEnq.js} +1 -1
  27. package/build/client/_app/immutable/nodes/0.Bv_TwEnq.js.br +0 -0
  28. package/build/client/_app/immutable/nodes/0.Bv_TwEnq.js.gz +0 -0
  29. package/build/client/_app/immutable/nodes/{1.DMPyoM-M.js → 1.7lffTIeb.js} +1 -1
  30. package/build/client/_app/immutable/nodes/1.7lffTIeb.js.br +0 -0
  31. package/build/client/_app/immutable/nodes/1.7lffTIeb.js.gz +0 -0
  32. package/build/client/_app/immutable/nodes/{10.Cbm7nQKK.js → 10.ChiIrIDl.js} +1 -1
  33. package/build/client/_app/immutable/nodes/10.ChiIrIDl.js.br +0 -0
  34. package/build/client/_app/immutable/nodes/10.ChiIrIDl.js.gz +0 -0
  35. package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js → 11.DO3vyXEv.js} +2 -2
  36. package/build/client/_app/immutable/nodes/11.DO3vyXEv.js.br +0 -0
  37. package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js.gz → 11.DO3vyXEv.js.gz} +0 -0
  38. package/build/client/_app/immutable/nodes/2.iMIqsE7n.js +23 -0
  39. package/build/client/_app/immutable/nodes/2.iMIqsE7n.js.br +0 -0
  40. package/build/client/_app/immutable/nodes/2.iMIqsE7n.js.gz +0 -0
  41. package/build/client/_app/immutable/nodes/{3.BgLpGnzb.js → 3.CArnSHOO.js} +1 -1
  42. package/build/client/_app/immutable/nodes/3.CArnSHOO.js.br +0 -0
  43. package/build/client/_app/immutable/nodes/3.CArnSHOO.js.gz +0 -0
  44. package/build/client/_app/immutable/nodes/{5.Avc1-gVb.js → 5.DziEu9rx.js} +1 -1
  45. package/build/client/_app/immutable/nodes/5.DziEu9rx.js.br +0 -0
  46. package/build/client/_app/immutable/nodes/5.DziEu9rx.js.gz +0 -0
  47. package/build/client/_app/immutable/nodes/{6.Dw2wEssJ.js → 6.B8l1RwkB.js} +1 -1
  48. package/build/client/_app/immutable/nodes/6.B8l1RwkB.js.br +0 -0
  49. package/build/client/_app/immutable/nodes/6.B8l1RwkB.js.gz +0 -0
  50. package/build/client/_app/immutable/nodes/{7.DwKZjoBg.js → 7.BPyfhDis.js} +1 -1
  51. package/build/client/_app/immutable/nodes/7.BPyfhDis.js.br +0 -0
  52. package/build/client/_app/immutable/nodes/7.BPyfhDis.js.gz +0 -0
  53. package/build/client/_app/immutable/nodes/{8.ZUAI6g5E.js → 8.D_vszZ9E.js} +1 -1
  54. package/build/client/_app/immutable/nodes/8.D_vszZ9E.js.br +0 -0
  55. package/build/client/_app/immutable/nodes/8.D_vszZ9E.js.gz +0 -0
  56. package/build/client/_app/immutable/nodes/{9.I_KGXPwB.js → 9.Drah-do-.js} +1 -1
  57. package/build/client/_app/immutable/nodes/9.Drah-do-.js.br +0 -0
  58. package/build/client/_app/immutable/nodes/9.Drah-do-.js.gz +0 -0
  59. package/build/client/_app/version.json +1 -1
  60. package/build/client/_app/version.json.br +0 -0
  61. package/build/client/_app/version.json.gz +0 -0
  62. package/build/pty-holder.cjs +6 -0
  63. package/build/server/chunks/{0-vrTNAfZB.js → 0-DAB_6Vm1.js} +2 -2
  64. package/build/server/chunks/{0-vrTNAfZB.js.map → 0-DAB_6Vm1.js.map} +1 -1
  65. package/build/server/chunks/{1-nbr-bOoF.js → 1-D-qMYaCx.js} +2 -2
  66. package/build/server/chunks/{1-nbr-bOoF.js.map → 1-D-qMYaCx.js.map} +1 -1
  67. package/build/server/chunks/{10-ChyvvJ6w.js → 10-CeFFGo-X.js} +2 -2
  68. package/build/server/chunks/{10-ChyvvJ6w.js.map → 10-CeFFGo-X.js.map} +1 -1
  69. package/build/server/chunks/{11-6ZAjL3uU.js → 11-DRMu_ATU.js} +2 -2
  70. package/build/server/chunks/{11-6ZAjL3uU.js.map → 11-DRMu_ATU.js.map} +1 -1
  71. package/build/server/chunks/{2-DWFRVDWJ.js → 2-B7OLBMNH.js} +4 -4
  72. package/build/server/chunks/{2-DWFRVDWJ.js.map → 2-B7OLBMNH.js.map} +1 -1
  73. package/build/server/chunks/{3-CKANM_WM.js → 3-B38ZarLw.js} +2 -2
  74. package/build/server/chunks/{3-CKANM_WM.js.map → 3-B38ZarLw.js.map} +1 -1
  75. package/build/server/chunks/{5-BxVjs2qi.js → 5-D-Uv1voC.js} +2 -2
  76. package/build/server/chunks/{5-BxVjs2qi.js.map → 5-D-Uv1voC.js.map} +1 -1
  77. package/build/server/chunks/{6-Cbf1AAMQ.js → 6-DP46cUej.js} +2 -2
  78. package/build/server/chunks/{6-Cbf1AAMQ.js.map → 6-DP46cUej.js.map} +1 -1
  79. package/build/server/chunks/{7-CMK2quEf.js → 7-B29_3ar6.js} +2 -2
  80. package/build/server/chunks/{7-CMK2quEf.js.map → 7-B29_3ar6.js.map} +1 -1
  81. package/build/server/chunks/{8-DhdfkfDM.js → 8-DCnSDVrX.js} +2 -2
  82. package/build/server/chunks/{8-DhdfkfDM.js.map → 8-DCnSDVrX.js.map} +1 -1
  83. package/build/server/chunks/{9-CPpxtRM5.js → 9-BwqDc8wC.js} +2 -2
  84. package/build/server/chunks/{9-CPpxtRM5.js.map → 9-BwqDc8wC.js.map} +1 -1
  85. package/build/server/chunks/_page.svelte-8OFzwdNA.js +758 -0
  86. package/build/server/chunks/_page.svelte-8OFzwdNA.js.map +1 -0
  87. package/build/server/chunks/{_server.ts-BWVlO8iV.js → _server.ts-05JJOdcX.js} +15 -12
  88. package/build/server/chunks/_server.ts-05JJOdcX.js.map +1 -0
  89. package/build/server/chunks/{_server.ts-BevnuePu.js → _server.ts-BCljU9Sg.js} +7 -3
  90. package/build/server/chunks/_server.ts-BCljU9Sg.js.map +1 -0
  91. package/build/server/chunks/{_server.ts-D-vgx5UZ.js → _server.ts-BTmknWpO.js} +2 -2
  92. package/build/server/chunks/{_server.ts-D-vgx5UZ.js.map → _server.ts-BTmknWpO.js.map} +1 -1
  93. package/build/server/chunks/{_server.ts-tChyh9FX.js → _server.ts-BXhmLZwN.js} +4 -2
  94. package/build/server/chunks/{_server.ts-tChyh9FX.js.map → _server.ts-BXhmLZwN.js.map} +1 -1
  95. package/build/server/chunks/{_server.ts-CvJKTS3Z.js → _server.ts-BbRSpB74.js} +4 -2
  96. package/build/server/chunks/{_server.ts-CvJKTS3Z.js.map → _server.ts-BbRSpB74.js.map} +1 -1
  97. package/build/server/chunks/{_server.ts-CC2K8-L2.js → _server.ts-Blx6TuRU.js} +4 -2
  98. package/build/server/chunks/_server.ts-Blx6TuRU.js.map +1 -0
  99. package/build/server/chunks/_server.ts-C6NRpe7e.js +33 -0
  100. package/build/server/chunks/_server.ts-C6NRpe7e.js.map +1 -0
  101. package/build/server/chunks/_server.ts-CGqCOCdK.js +53 -0
  102. package/build/server/chunks/_server.ts-CGqCOCdK.js.map +1 -0
  103. package/build/server/chunks/{_server.ts-X1R7L_QI.js → _server.ts-CYWXjihn.js} +4 -2
  104. package/build/server/chunks/{_server.ts-X1R7L_QI.js.map → _server.ts-CYWXjihn.js.map} +1 -1
  105. package/build/server/chunks/{_server.ts-CD7JP3fz.js → _server.ts-D0___krA.js} +4 -2
  106. package/build/server/chunks/_server.ts-D0___krA.js.map +1 -0
  107. package/build/server/chunks/{_server.ts-VzDcFFgy.js → _server.ts-DPHRUFYS.js} +4 -2
  108. package/build/server/chunks/_server.ts-DPHRUFYS.js.map +1 -0
  109. package/build/server/chunks/{_server.ts-D0zRDSx0.js → _server.ts-D_WRex0k.js} +4 -2
  110. package/build/server/chunks/_server.ts-D_WRex0k.js.map +1 -0
  111. package/build/server/chunks/{_server.ts-CA5KUENM.js → _server.ts-Da1kSClZ.js} +4 -2
  112. package/build/server/chunks/_server.ts-Da1kSClZ.js.map +1 -0
  113. package/build/server/chunks/{_server.ts-Dp-hXW_I.js → _server.ts-l3cd4Cto.js} +4 -2
  114. package/build/server/chunks/_server.ts-l3cd4Cto.js.map +1 -0
  115. package/build/server/chunks/{library-apns-Dl3iRE2h.js → library-apns-D8RPINlv.js} +62 -7
  116. package/build/server/chunks/library-apns-D8RPINlv.js.map +1 -0
  117. package/build/server/chunks/{pending-requests-C9p57WoU.js → pending-requests-8rWjrF6d.js} +3 -2
  118. package/build/server/chunks/pending-requests-8rWjrF6d.js.map +1 -0
  119. package/build/server/chunks/presence-store-Bx_g0-Gd.js +23 -0
  120. package/build/server/chunks/presence-store-Bx_g0-Gd.js.map +1 -0
  121. package/build/server/chunks/{pty-manager-ZqXqa-6A.js → pty-manager-DDjG7DlH.js} +297 -31
  122. package/build/server/chunks/pty-manager-DDjG7DlH.js.map +1 -0
  123. package/build/server/chunks/shooter-home-4f_HkdGI.js +10 -0
  124. package/build/server/chunks/shooter-home-4f_HkdGI.js.map +1 -0
  125. package/build/server/index.js +1 -1
  126. package/build/server/index.js.map +1 -1
  127. package/build/server/manifest.js +38 -24
  128. package/build/server/manifest.js.map +1 -1
  129. package/package.json +4 -2
  130. package/server.ts +2 -2
  131. package/src/lib/modules/client/common/index.ts +1 -0
  132. package/src/lib/modules/client/common/presence.ts +47 -0
  133. package/src/lib/modules/client/dashboard/AutopilotPanel.svelte +188 -4
  134. package/src/lib/modules/client/dashboard/autopilot-driver.svelte.ts +681 -0
  135. package/src/lib/modules/client/dashboard/decide-injection.ts +127 -0
  136. package/src/lib/modules/client/dashboard/store.svelte.ts +65 -24
  137. package/src/lib/modules/client/neurolink/fetch-proxy.ts +38 -1
  138. package/src/lib/modules/client/terminal/xterm-wrapper.ts +52 -12
  139. package/src/lib/modules/server/apn/apns-payload.ts +50 -0
  140. package/src/lib/modules/server/apn/library-apns.ts +50 -8
  141. package/src/lib/modules/server/apn/pending-requests.ts +3 -1
  142. package/src/lib/modules/server/sessions/autopilot-context.ts +57 -0
  143. package/src/lib/modules/server/sessions/autopilot-engine.ts +148 -43
  144. package/src/lib/modules/server/sessions/litellm-client.ts +90 -34
  145. package/src/lib/modules/server/sessions/next-step-consensus.ts +27 -2
  146. package/src/lib/modules/server/sessions/summary-store.ts +3 -1
  147. package/src/lib/modules/server/terminal/agent-launch.ts +26 -0
  148. package/src/lib/modules/server/terminal/pty-holder.cjs +6 -0
  149. package/src/lib/modules/server/terminal/pty-manager.ts +292 -38
  150. package/src/lib/modules/server/terminal/session-watcher.ts +12 -2
  151. package/src/lib/modules/server/terminal/terminal-emulator.ts +102 -0
  152. package/src/lib/modules/server/terminal/terminal-store.ts +3 -1
  153. package/src/lib/modules/server/utils/shooter-home.ts +16 -0
  154. package/src/lib/modules/server/ws/presence-store.ts +50 -0
  155. package/src/lib/modules/server/ws/server.ts +18 -2
  156. package/src/lib/modules/server/ws/terminal-handler.ts +11 -6
  157. package/src/lib/types/autopilot.ts +65 -0
  158. package/src/lib/types/generated/WsProtocol.ts +10 -1
  159. package/src/lib/types/server.ts +27 -1
  160. package/src/lib/types/terminal-client.ts +3 -0
  161. package/src/lib/types/ws.ts +3 -2
  162. package/src/routes/api/autopilot/goal/+server.ts +72 -0
  163. package/src/routes/api/neurolink-proxy/+server.ts +8 -2
  164. package/src/routes/api/notify/+server.ts +22 -15
  165. package/src/routes/api/presence/+server.ts +39 -0
  166. package/src/routes/api/ws-status/+server.ts +8 -5
  167. package/build/client/_app/immutable/assets/2.BHi6pjT2.css +0 -1
  168. package/build/client/_app/immutable/assets/2.BHi6pjT2.css.br +0 -0
  169. package/build/client/_app/immutable/assets/2.BHi6pjT2.css.gz +0 -0
  170. package/build/client/_app/immutable/chunks/BmfLecb1.js.br +0 -0
  171. package/build/client/_app/immutable/chunks/BmfLecb1.js.gz +0 -0
  172. package/build/client/_app/immutable/chunks/CRkG7oE4.js +0 -1
  173. package/build/client/_app/immutable/chunks/CRkG7oE4.js.br +0 -0
  174. package/build/client/_app/immutable/chunks/CRkG7oE4.js.gz +0 -0
  175. package/build/client/_app/immutable/chunks/DOEXXmsh.js.br +0 -0
  176. package/build/client/_app/immutable/chunks/DOEXXmsh.js.gz +0 -0
  177. package/build/client/_app/immutable/chunks/EqMAkEha.js.br +0 -0
  178. package/build/client/_app/immutable/chunks/EqMAkEha.js.gz +0 -0
  179. package/build/client/_app/immutable/chunks/J5-Cr5oR.js +0 -6
  180. package/build/client/_app/immutable/chunks/J5-Cr5oR.js.br +0 -0
  181. package/build/client/_app/immutable/chunks/J5-Cr5oR.js.gz +0 -0
  182. package/build/client/_app/immutable/entry/app.CeSxgGat.js.br +0 -0
  183. package/build/client/_app/immutable/entry/app.CeSxgGat.js.gz +0 -0
  184. package/build/client/_app/immutable/entry/start.DrnJFwxA.js +0 -1
  185. package/build/client/_app/immutable/entry/start.DrnJFwxA.js.br +0 -2
  186. package/build/client/_app/immutable/entry/start.DrnJFwxA.js.gz +0 -0
  187. package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.br +0 -0
  188. package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.gz +0 -0
  189. package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.br +0 -0
  190. package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.gz +0 -0
  191. package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.br +0 -0
  192. package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.gz +0 -0
  193. package/build/client/_app/immutable/nodes/11.CKmZjP_a.js.br +0 -0
  194. package/build/client/_app/immutable/nodes/2.zlrdNFtH.js +0 -13
  195. package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.br +0 -0
  196. package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.gz +0 -0
  197. package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.br +0 -0
  198. package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.gz +0 -0
  199. package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.br +0 -0
  200. package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.gz +0 -0
  201. package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.br +0 -0
  202. package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.gz +0 -0
  203. package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.br +0 -0
  204. package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.gz +0 -0
  205. package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.br +0 -0
  206. package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.gz +0 -0
  207. package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.br +0 -0
  208. package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.gz +0 -0
  209. package/build/server/chunks/_page.svelte-tBuIq8Pg.js +0 -159
  210. package/build/server/chunks/_page.svelte-tBuIq8Pg.js.map +0 -1
  211. package/build/server/chunks/_server.ts-BWVlO8iV.js.map +0 -1
  212. package/build/server/chunks/_server.ts-BevnuePu.js.map +0 -1
  213. package/build/server/chunks/_server.ts-CA5KUENM.js.map +0 -1
  214. package/build/server/chunks/_server.ts-CC2K8-L2.js.map +0 -1
  215. package/build/server/chunks/_server.ts-CD7JP3fz.js.map +0 -1
  216. package/build/server/chunks/_server.ts-D0zRDSx0.js.map +0 -1
  217. package/build/server/chunks/_server.ts-Dp-hXW_I.js.map +0 -1
  218. package/build/server/chunks/_server.ts-VzDcFFgy.js.map +0 -1
  219. package/build/server/chunks/library-apns-Dl3iRE2h.js.map +0 -1
  220. package/build/server/chunks/pending-requests-C9p57WoU.js.map +0 -1
  221. package/build/server/chunks/pty-manager-ZqXqa-6A.js.map +0 -1
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Server-side authoritative terminal emulator (Phase 1).
3
+ *
4
+ * Wraps a DOM-free @xterm/headless Terminal + @xterm/addon-serialize. Every
5
+ * PTY output chunk is fed in via write(); snapshot() returns a VT-escape string
6
+ * that reconstructs the CURRENT screen — including the alternate buffer (TUIs
7
+ * like vim/htop) and modes — when written into a fresh terminal. This is what
8
+ * a new or reconnecting client receives instead of a raw scrollback replay,
9
+ * fixing late-join corruption (G2) and the scrollback/live duplication race (G3).
10
+ *
11
+ * Caveats handled here (see the keystone spike): @xterm/addon-serialize does
12
+ * NOT serialize cursor visibility (DECTCEM ?25l), so we track it from the byte
13
+ * stream and re-emit it; cursor position restores functionally but not
14
+ * byte-exactly (do not assert byte-equality). Pinned to @xterm/headless@6.0.0
15
+ * and @xterm/addon-serialize@0.14.0 (serialize() reaches into _core internals).
16
+ *
17
+ * Interop note: both packages export via CJS in a way tsx/Node cannot resolve
18
+ * as named ESM imports, so the runtime values come through createRequire() while
19
+ * the types are imported separately.
20
+ */
21
+
22
+ import type { TerminalSnapshot } from '$lib/types';
23
+ import type { SerializeAddon as SerializeAddonInstance } from '@xterm/addon-serialize';
24
+ import type { Terminal as HeadlessTerminal } from '@xterm/headless';
25
+
26
+ import { createRequire } from 'node:module';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const { Terminal } = require('@xterm/headless') as {
30
+ Terminal: new (options?: object) => HeadlessTerminal;
31
+ };
32
+ const { SerializeAddon } = require('@xterm/addon-serialize') as {
33
+ SerializeAddon: new () => SerializeAddonInstance;
34
+ };
35
+
36
+ /** Scrollback lines retained in the emulator and included in snapshots. */
37
+ const SNAPSHOT_SCROLLBACK_LINES = 1000;
38
+
39
+ const HIDE_CURSOR = '\x1b[?25l';
40
+ const SHOW_CURSOR = '\x1b[?25h';
41
+
42
+ export class TerminalEmulator {
43
+ private cursorHidden = false;
44
+ private readonly serializer: SerializeAddonInstance;
45
+ private readonly term: HeadlessTerminal;
46
+
47
+ constructor(cols: number, rows: number) {
48
+ this.term = new Terminal({
49
+ allowProposedApi: true,
50
+ cols: cols > 0 ? cols : 80,
51
+ rows: rows > 0 ? rows : 24,
52
+ scrollback: SNAPSHOT_SCROLLBACK_LINES,
53
+ });
54
+ this.serializer = new SerializeAddon();
55
+ // @xterm/addon-serialize types its addon against @xterm/xterm's Terminal,
56
+ // but we run it on @xterm/headless's Terminal. Runtime-compatible; the cast
57
+ // bridges the two structurally-different Terminal types at loadAddon only.
58
+ this.term.loadAddon(this.serializer as unknown as Parameters<HeadlessTerminal['loadAddon']>[0]);
59
+ }
60
+
61
+ dispose(): void {
62
+ try {
63
+ this.serializer.dispose();
64
+ this.term.dispose();
65
+ } catch {
66
+ // Already disposed — ignore.
67
+ }
68
+ }
69
+
70
+ resize(cols: number, rows: number): void {
71
+ if (cols > 0 && rows > 0) {
72
+ this.term.resize(cols, rows);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Capture the current screen as a VT-escape string. Serialization runs inside
78
+ * a write() callback so all previously-written bytes are parsed first.
79
+ */
80
+ snapshot(): Promise<TerminalSnapshot> {
81
+ return new Promise<TerminalSnapshot>((resolve) => {
82
+ this.term.write('', () => {
83
+ let data = this.serializer.serialize({ scrollback: SNAPSHOT_SCROLLBACK_LINES });
84
+ // SerializeAddon omits cursor visibility — re-emit when hidden.
85
+ if (this.cursorHidden) {
86
+ data += HIDE_CURSOR;
87
+ }
88
+ resolve({ cols: this.term.cols, data, rows: this.term.rows });
89
+ });
90
+ });
91
+ }
92
+
93
+ write(data: string): void {
94
+ // Track cursor visibility (DECTCEM) from the stream; last toggle wins.
95
+ const hideIdx = data.lastIndexOf(HIDE_CURSOR);
96
+ const showIdx = data.lastIndexOf(SHOW_CURSOR);
97
+ if (hideIdx !== -1 || showIdx !== -1) {
98
+ this.cursorHidden = hideIdx > showIdx;
99
+ }
100
+ this.term.write(data);
101
+ }
102
+ }
@@ -13,9 +13,11 @@ import Database from 'better-sqlite3';
13
13
  import * as fs from 'fs';
14
14
  import * as path from 'path';
15
15
 
16
+ import { shooterDataDir } from '../utils/shooter-home.js';
17
+
16
18
  // ── Constants ────────────────────────────────────────────────────────
17
19
 
18
- const DB_DIR = path.join(process.env.HOME || '', '.shooter');
20
+ const DB_DIR = shooterDataDir();
19
21
  const DB_PATH = path.join(DB_DIR, 'shooter.db');
20
22
 
21
23
  // ── Snake/Camel Conversion ───────────────────────────────────────────
@@ -0,0 +1,16 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * The Shooter data directory — the SQLite DB and engine/permission state live here.
6
+ *
7
+ * Honors `SHOOTER_HOME` so an isolated instance (a test server, or a second server run alongside
8
+ * the user's daemon) keeps its DB/state separate from the default `~/.shooter` instead of sharing
9
+ * it (which would let one server's `reconnectAll()` adopt the other's live terminals). This matches
10
+ * the CLI (`bin/shooter.cjs`) and the env loader (`env.ts`), which already treat `SHOOTER_HOME` as
11
+ * the data dir. When unset, falls back to `~/.shooter`.
12
+ */
13
+ export function shooterDataDir(): string {
14
+ const override = process.env.SHOOTER_HOME?.trim();
15
+ return override && override.length > 0 ? override : join(homedir(), '.shooter');
16
+ }
@@ -0,0 +1,50 @@
1
+ // Viewer-presence store: "is someone actually watching" (foreground) vs "away".
2
+ //
3
+ // Why this exists: the phone-resident autonomous loop holds a PERSISTENT /ws/events
4
+ // connection to run. So raw WebSocket-connection count no longer means "the user is
5
+ // watching" — it's always > 0. The autopilot push decision must instead key on whether a
6
+ // viewer is FOREGROUNDED (heartbeat within TTL), reported via POST /api/presence.
7
+ //
8
+ // globalThis singleton so the SvelteKit route (which writes it) and the server.ts engine
9
+ // (which reads it) share one instance across the dual module graph — same pattern as the
10
+ // ws ticket store and the event-listener registry.
11
+
12
+ const PRESENCE_TTL_MS = 45_000;
13
+ const KEY = '__shooter_presence';
14
+
15
+ // eslint-disable-next-line no-restricted-syntax -- internal singleton shape, never exported
16
+ interface PresenceRecord {
17
+ everReported: boolean;
18
+ lastForegroundAt: number;
19
+ }
20
+
21
+ /** True once any client has ever reported presence (used for backward-compatible fallback). */
22
+ export function hasEverReported(): boolean {
23
+ return store().everReported;
24
+ }
25
+
26
+ /** True when a viewer reported `foreground` within the TTL window. */
27
+ export function isViewerPresent(
28
+ now: number = Date.now(),
29
+ ttlMs: number = PRESENCE_TTL_MS
30
+ ): boolean {
31
+ return now - store().lastForegroundAt < ttlMs;
32
+ }
33
+
34
+ /**
35
+ * Record a presence heartbeat. `foreground` marks the viewer as actively watching (now);
36
+ * `background` marks them away immediately (so push resumes without waiting out the TTL).
37
+ */
38
+ export function reportPresence(state: 'background' | 'foreground', now: number = Date.now()): void {
39
+ const s = store();
40
+ s.everReported = true;
41
+ s.lastForegroundAt = state === 'foreground' ? now : 0;
42
+ }
43
+
44
+ function store(): PresenceRecord {
45
+ const g = globalThis as Record<string, unknown>;
46
+ if (!g[KEY]) {
47
+ g[KEY] = { everReported: false, lastForegroundAt: 0 };
48
+ }
49
+ return g[KEY] as PresenceRecord;
50
+ }
@@ -56,8 +56,24 @@ export function setupWebSocketHandlers(
56
56
  ): void {
57
57
  const host = request.headers.host ?? 'localhost';
58
58
  let pathname: string;
59
+ let snapshotCapable = false;
60
+ let lastSeq = 0;
59
61
  try {
60
- pathname = new URL(request.url || '/', `http://${host}`).pathname;
62
+ const url = new URL(request.url || '/', `http://${host}`);
63
+ pathname = url.pathname;
64
+ // Capability negotiation: clients that understand the {snapshot} frame
65
+ // advertise ?caps=snapshot. Others fall back to raw scrollback replay.
66
+ snapshotCapable = url.searchParams.get('caps') === 'snapshot';
67
+ // Reconnect resume (Phase 2): a returning client passes the highest output
68
+ // seq it already applied so the server can replay just the gap (or snapshot
69
+ // if the gap aged out of the ring). Absent / non-numeric ⇒ fresh join.
70
+ const rawLastSeq = url.searchParams.get('lastSeq');
71
+ if (rawLastSeq !== null) {
72
+ const parsed = Number(rawLastSeq);
73
+ if (Number.isFinite(parsed) && parsed > 0) {
74
+ lastSeq = Math.floor(parsed);
75
+ }
76
+ }
61
77
  } catch {
62
78
  socket.destroy();
63
79
  return;
@@ -103,7 +119,7 @@ export function setupWebSocketHandlers(
103
119
 
104
120
  if (terminalMatch) {
105
121
  const terminalId = terminalMatch[1];
106
- handleTerminalConnection(ws, terminalId, scope);
122
+ handleTerminalConnection(ws, terminalId, scope, snapshotCapable, lastSeq);
107
123
  } else if (superSessionMatch) {
108
124
  const superSessionId = superSessionMatch[1];
109
125
  handleSuperSessionConnection(ws, superSessionId);
@@ -32,7 +32,9 @@ let _ptyManager: null | PtyManagerLike = null;
32
32
  export function handleTerminalConnection(
33
33
  ws: WebSocket,
34
34
  terminalId: string,
35
- scope?: TicketScope
35
+ scope?: TicketScope,
36
+ snapshotCapable = false,
37
+ lastSeq = 0
36
38
  ): void {
37
39
  // ── 1. Look up the terminal ──────────────────────────────────────
38
40
  if (!_ptyManager) {
@@ -48,11 +50,14 @@ export function handleTerminalConnection(
48
50
  return;
49
51
  }
50
52
 
51
- // ── 2. Attach via pty-manager (registers client + sends scrollback) ──
52
- // The pty-manager's attach() adds ws to terminal.clients AND
53
- // terminal.outputBuffers, then replays scrollback. Its broadcastOutput()
54
- // loop delivers all PTY output no per-client onData listener needed.
55
- _ptyManager.attach(terminalId, ws);
53
+ // ── 2. Attach via pty-manager (registers client + sends initial state) ──
54
+ // Snapshot-capable clients (?caps=snapshot) get a serialized current-screen
55
+ // snapshot; others get the legacy raw scrollback replay. A reconnecting
56
+ // client also passes lastSeq (>0) so attach() can replay only the missing
57
+ // frames from the ring instead of re-snapshotting. attach() adds the ws to
58
+ // terminal.clients + outputBuffers; broadcastOutput() then delivers all live
59
+ // PTY output — no per-client onData listener needed.
60
+ _ptyManager.attach(terminalId, ws, { lastSeq, snapshot: snapshotCapable });
56
61
 
57
62
  // If the terminal already exited, tell the client immediately.
58
63
  if (terminal.status === 'exited') {
@@ -15,6 +15,16 @@ export interface AutopilotState {
15
15
  /** Per-session autopilot lifecycle state. */
16
16
  export type AutopilotStatus = 'error' | 'idle' | 'running';
17
17
 
18
+ /** Verdict from guardCommand() — whether a concrete command is safe to write to the PTY. */
19
+ export interface CommandVerdict {
20
+ /** The trimmed command (echoed back for convenience). */
21
+ command: string;
22
+ /** Human-readable reason for the verdict. */
23
+ reason: string;
24
+ /** True when the command passed all guards and may be injected. */
25
+ safe: boolean;
26
+ }
27
+
18
28
  /** Result from mergeNextStepConsensus(). */
19
29
  export interface ConsensusResult {
20
30
  /** Number of agent lists passed in. */
@@ -25,6 +35,61 @@ export interface ConsensusResult {
25
35
  steps: NextStep[];
26
36
  }
27
37
 
38
+ /** A record of one autonomous-loop decision, surfaced in the dashboard panel. */
39
+ export interface DriverAction {
40
+ /** ms timestamp of the action. */
41
+ at: number;
42
+ /** The command injected, or a short reason it was skipped / failed. */
43
+ detail: string;
44
+ /** What happened. */
45
+ kind: DriverActionKind;
46
+ /** The terminal acted on. */
47
+ terminalId: string;
48
+ }
49
+
50
+ /** Outcome of one driver evaluation. */
51
+ export type DriverActionKind = 'error' | 'injected' | 'skipped';
52
+
53
+ /** Decision from decideInjection() — the gate deciding whether to auto-act. */
54
+ export interface GateDecision {
55
+ /** True when all safety gates pass and the loop should produce + inject a command. */
56
+ act: boolean;
57
+ /** Human-readable reason (always set, for logging + the phone UI). */
58
+ reason: string;
59
+ /** The consensus step being acted on (present when act is true). */
60
+ step?: NextStep;
61
+ }
62
+
63
+ /** Tunable thresholds for the auto-inject safety gate. */
64
+ export interface InjectionPolicy {
65
+ /** Suppress injection within this many ms of observed human / output activity. */
66
+ humanGraceMs: number;
67
+ /** Minimum confidence of the top consensus step required to inject. */
68
+ injectConfidence: number;
69
+ /** Stop auto-injecting a terminal after this many consecutive actions without progress. */
70
+ maxAutoActions: number;
71
+ /** Minimum gap in ms between injections into the same terminal. */
72
+ minIntervalMs: number;
73
+ }
74
+
75
+ /** Per-terminal snapshot the driver passes to decideInjection(). */
76
+ export interface InjectionState {
77
+ /** Consecutive auto-injections without a human touch or successful tool completion. */
78
+ autoActionCount: number;
79
+ /** True only for terminals Shooter created (POST /api/terminals); external sessions are read-only. */
80
+ isManaged: boolean;
81
+ /** Normalized text of the last consensus step acted on (dedup guard). */
82
+ lastActedStep: null | string;
83
+ /** ms timestamp of the last observed human input / terminal output activity. */
84
+ lastActivityAt: number;
85
+ /** The most recent WireShooterEvent type seen for this terminal. */
86
+ lastEventType: string;
87
+ /** ms timestamp of the last command injection into this terminal. */
88
+ lastInjectedAt: number;
89
+ /** The terminal id. */
90
+ terminalId: string;
91
+ }
92
+
28
93
  /** Options for mergeNextStepConsensus(). */
29
94
  export interface MergeOptions {
30
95
  /** Max steps taken from each agent list (default 3). */
@@ -238,20 +238,29 @@ export type TerminalOutputMessage = {
238
238
  * @memberof TerminalOutputMessage
239
239
  */
240
240
  data: string;
241
+ /**
242
+ * @description Monotonic per-terminal sequence number (starts at 1, increments by 1 for every broadcast output frame). Clients track lastSeq to enable gap detection and reconnect resume (Phase 2).
243
+
244
+ * @type { number }
245
+ * @memberof TerminalOutputMessage
246
+ */
247
+ seq: number;
241
248
  };
242
249
 
243
250
  export function decodeTerminalOutputMessage(rawInput: unknown): TerminalOutputMessage | null {
244
251
  if (isJSON(rawInput)) {
245
252
  const decodedType = decodeString(rawInput['type']);
246
253
  const decodedData = decodeString(rawInput['data']);
254
+ const decodedSeq = decodeNumber(rawInput['seq']);
247
255
 
248
- if (decodedType === null || decodedData === null) {
256
+ if (decodedType === null || decodedData === null || decodedSeq === null) {
249
257
  return null;
250
258
  }
251
259
 
252
260
  return {
253
261
  type: decodedType,
254
262
  data: decodedData,
263
+ seq: decodedSeq,
255
264
  };
256
265
  }
257
266
  return null;
@@ -10,6 +10,7 @@ import type WebSocket from 'ws';
10
10
 
11
11
  import type { CodexStreamParser } from '../modules/server/sessions/codex-parser';
12
12
  import type { HolderClient } from '../modules/server/terminal/holder-client';
13
+ import type { TerminalEmulator } from '../modules/server/terminal/terminal-emulator';
13
14
  import type { ConversationMessage } from './sessions';
14
15
 
15
16
  // ── holder-client types ─────────────────────────────────────────────
@@ -87,6 +88,8 @@ export interface PtyManagedTerminal {
87
88
  createdAt: Date;
88
89
  currentCwd: null | string;
89
90
  cwd: string;
91
+ /** Server-side headless emulator for snapshot-on-join (null when disabled). */
92
+ emulator: null | TerminalEmulator;
90
93
  exitCode: null | number;
91
94
  exitedAt: Date | null;
92
95
  holderPid: number;
@@ -100,6 +103,10 @@ export interface PtyManagedTerminal {
100
103
  pty: HolderClient;
101
104
  rows: number;
102
105
  scrollback: string;
106
+ /** Monotonic counter; equals the last assigned seq for this terminal. */
107
+ seqCounter: number;
108
+ /** Bounded replay ring of recent output chunks (max SEQ_RING_MAX_ENTRIES). */
109
+ seqRing: SeqRingEntry[];
103
110
  sessionFile: null | string;
104
111
  socketPath: string;
105
112
  status: 'exited' | 'running';
@@ -113,7 +120,14 @@ export interface PtyOutputBuffer {
113
120
  size: number;
114
121
  }
115
122
 
116
- // ── generic-session-watcher types ───────────────────────────────────
123
+ /**
124
+ * One entry in the per-terminal sequence ring (in-memory store type;
125
+ * structurally identical to the generated wire SeqRingEntry).
126
+ */
127
+ export interface SeqRingEntry {
128
+ data: string;
129
+ seq: number;
130
+ }
117
131
 
118
132
  export interface SessionWatchedFile {
119
133
  callbacks: Set<OnNewEntries>;
@@ -121,3 +135,15 @@ export interface SessionWatchedFile {
121
135
  offset: number;
122
136
  watcher: FSWatcher;
123
137
  }
138
+
139
+ // ── generic-session-watcher types ───────────────────────────────────
140
+
141
+ /**
142
+ * A serialized snapshot of a terminal's current screen (VT escape string)
143
+ * produced by the server-side headless emulator, plus its dimensions.
144
+ */
145
+ export interface TerminalSnapshot {
146
+ cols: number;
147
+ data: string;
148
+ rows: number;
149
+ }
@@ -108,6 +108,8 @@ export interface ShortcutsHelpProps {
108
108
  export interface TerminalInstance {
109
109
  dispose: () => void;
110
110
  fitAddon: FitAddon | null;
111
+ /** Highest output `seq` seen from the server (for Phase 2 reconnect resume). */
112
+ getLastSeq: () => number;
111
113
  sendInput: (data: string) => void;
112
114
  term: Terminal;
113
115
  }
@@ -147,5 +149,6 @@ export interface WsTerminalInboundMessage {
147
149
  data?: string;
148
150
  path?: string;
149
151
  rows?: number;
152
+ seq?: number;
150
153
  type: string;
151
154
  }
@@ -73,7 +73,7 @@ export interface TerminalManagedTerminal {
73
73
  }
74
74
 
75
75
  export interface TerminalPtyManagerLike {
76
- attach: (id: string, ws: WebSocket) => boolean;
76
+ attach: (id: string, ws: WebSocket, opts?: { lastSeq?: number; snapshot?: boolean }) => boolean;
77
77
  detach: (id: string, ws: WebSocket) => boolean;
78
78
  getTerminal: (id: string) => TerminalManagedTerminal | undefined;
79
79
  }
@@ -146,8 +146,9 @@ export type WireTerminalServerMessage =
146
146
  | { bytes: number; type: 'output-dropped' }
147
147
  | { chunk: number; data: string; total: number; type: 'scrollback' }
148
148
  | { code: null | number; signal: null | string; type: 'exit' }
149
+ | { cols: number; data: string; rows: number; seq: number; type: 'snapshot' }
149
150
  | { cols: number; rows: number; type: 'resize' }
150
- | { data: string; type: 'output' }
151
+ | { data: string; seq: number; type: 'output' }
151
152
  | { message: string; type: 'error' };
152
153
 
153
154
  // ── notification-sessions type alias ────────────────────────────────
@@ -0,0 +1,72 @@
1
+ // Set/read the per-terminal autopilot GOAL that anchors the engine's context every cycle.
2
+ //
3
+ // POST { terminalId, goal } pins the goal; GET ?terminalId=… reads it back. Like
4
+ // /api/autopilot, this reaches the engine via globalThis.__shooter_autopilot rather than
5
+ // importing it (importing would start a second event subscriber). Goals live in-memory in the
6
+ // engine (no file fallback): if the engine is not running there is nothing to set, so we 503.
7
+
8
+ import { validateAuth } from '$lib/modules/server/auth';
9
+ import { json } from '@sveltejs/kit';
10
+
11
+ import type { RequestHandler } from './$types';
12
+
13
+ function control():
14
+ | undefined
15
+ | {
16
+ getGoal?: (terminalId: string) => string | undefined;
17
+ setGoal?: (terminalId: string, goal: string) => void;
18
+ } {
19
+ return (globalThis as Record<string, unknown>).__shooter_autopilot as
20
+ | undefined
21
+ | {
22
+ getGoal?: (terminalId: string) => string | undefined;
23
+ setGoal?: (terminalId: string, goal: string) => void;
24
+ };
25
+ }
26
+
27
+ export const GET: RequestHandler = ({ request, url }) => {
28
+ const authError = validateAuth(request);
29
+ if (authError) {
30
+ return authError;
31
+ }
32
+ const terminalId = url.searchParams.get('terminalId') ?? '';
33
+ if (!terminalId) {
34
+ return json({ error: 'terminalId query param is required' }, { status: 400 });
35
+ }
36
+ const ctrl = control();
37
+ if (!ctrl?.getGoal) {
38
+ return json({ error: 'autopilot engine not running', running: false }, { status: 503 });
39
+ }
40
+ return json({ goal: ctrl.getGoal(terminalId) ?? null, running: true });
41
+ };
42
+
43
+ export const POST: RequestHandler = async ({ request }) => {
44
+ const authError = validateAuth(request);
45
+ if (authError) {
46
+ return authError;
47
+ }
48
+ let body: { goal?: unknown; terminalId?: unknown };
49
+ try {
50
+ body = (await request.json()) as { goal?: unknown; terminalId?: unknown };
51
+ } catch {
52
+ return json({ error: 'Invalid JSON body' }, { status: 400 });
53
+ }
54
+ if (typeof body.terminalId !== 'string' || body.terminalId.length === 0) {
55
+ return json({ error: 'terminalId must be a non-empty string' }, { status: 400 });
56
+ }
57
+ if (typeof body.goal !== 'string') {
58
+ return json({ error: 'goal must be a string' }, { status: 400 });
59
+ }
60
+ if (body.goal.length > 500) {
61
+ // The goal is prepended to EVERY engine LLM context, so an unbounded string would permanently
62
+ // bloat (and could dominate) the prompt. Cap it like the summaries route caps its fields.
63
+ return json({ error: 'goal must be 500 characters or fewer' }, { status: 400 });
64
+ }
65
+
66
+ const ctrl = control();
67
+ if (!ctrl?.setGoal) {
68
+ return json({ error: 'autopilot engine not running', running: false }, { status: 503 });
69
+ }
70
+ ctrl.setGoal(body.terminalId, body.goal);
71
+ return json({ goal: body.goal.trim() || null, running: true, terminalId: body.terminalId });
72
+ };
@@ -112,10 +112,16 @@ export const POST: RequestHandler = async ({ request }) => {
112
112
  // For Bearer-token providers, only inject the header when the key is non-empty
113
113
  // to avoid sending a malformed `Authorization: Bearer ` to the upstream.
114
114
  if (provider === 'anthropic') {
115
- forwardHeaders['x-api-key'] = apiKeyEnv.anthropic;
115
+ // Only inject the key header when non-empty — sending `x-api-key: ` (empty) is a malformed
116
+ // header that the upstream rejects with a confusing error instead of a clean 401.
117
+ if (apiKeyEnv.anthropic) {
118
+ forwardHeaders['x-api-key'] = apiKeyEnv.anthropic;
119
+ }
116
120
  forwardHeaders['anthropic-version'] = forwardHeaders['anthropic-version'] ?? '2023-06-01';
117
121
  } else if (provider === 'google-ai') {
118
- forwardHeaders['x-goog-api-key'] = apiKeyEnv['google-ai'];
122
+ if (apiKeyEnv['google-ai']) {
123
+ forwardHeaders['x-goog-api-key'] = apiKeyEnv['google-ai'];
124
+ }
119
125
  } else if (provider === 'openai') {
120
126
  if (apiKeyEnv.openai) {
121
127
  forwardHeaders.Authorization = `Bearer ${apiKeyEnv.openai}`;
@@ -173,13 +173,8 @@ function intelligentNotificationFilter(
173
173
  };
174
174
  }
175
175
 
176
- // Always allow Stop hook completion notifications
177
- if (source === 'stop-hook') {
178
- return {
179
- reason: 'Stop hook completion notification - session finished',
180
- send: true,
181
- };
182
- }
176
+ // (Removed a dead `source === 'stop-hook'` branch: the notifier emits
177
+ // 'shooter-completion-detector', never 'stop-hook', and the default below already allows it.)
183
178
 
184
179
  // Filter out only very specific spam patterns to be less restrictive
185
180
  const spamPatterns = [
@@ -263,18 +258,28 @@ function isDuplicateNotification(
263
258
  }
264
259
  }
265
260
 
266
- // Do NOT record here -- caller must call recordNotification() after
267
- // successful delivery to avoid cache poisoning on send failure.
261
+ // RESERVE the slot atomically (check-and-set): a second concurrent request with the same key now
262
+ // sees it as a duplicate before either has delivered, closing the TOCTOU window that let two
263
+ // identical pushes through. The delivery path RELEASES the slot (releaseNotification) if the send
264
+ // fails, so a legitimate retry is not blocked — this replaces the old record-only-on-success
265
+ // scheme while still avoiding cache poisoning on failure.
266
+ notificationCache.set(key, now);
268
267
  return false;
269
268
  }
270
269
 
271
- /** Record a notification key in the dedup cache after successful delivery. */
272
- function recordNotification(title: string, message: string, data?: NotificationData): void {
270
+ function notificationKey(title: string, message: string, data?: NotificationData): string {
273
271
  const dataRecord = data as (NotificationData & { dedupKey?: string }) | undefined;
274
- const key = dataRecord?.dedupKey
275
- ? dataRecord.dedupKey
276
- : `${title}|${message}|${data?.category || 'unknown'}`;
277
- notificationCache.set(key, Date.now());
272
+ return dataRecord?.dedupKey ?? `${title}|${message}|${data?.category || 'unknown'}`;
273
+ }
274
+
275
+ /** Refresh a reserved dedup key after successful delivery (keeps the window measured from send). */
276
+ function recordNotification(title: string, message: string, data?: NotificationData): void {
277
+ notificationCache.set(notificationKey(title, message, data), Date.now());
278
+ }
279
+
280
+ /** Release a reserved dedup key when delivery failed, so a legitimate retry is not blocked. */
281
+ function releaseNotification(title: string, message: string, data?: NotificationData): void {
282
+ notificationCache.delete(notificationKey(title, message, data));
278
283
  }
279
284
 
280
285
  // TODO(refactor): extract body parsing, filtering, and platform routing into
@@ -501,6 +506,7 @@ export const POST: RequestHandler = async ({ request }) => {
501
506
  });
502
507
  } else {
503
508
  console.error(`[notify] FCM delivery failed: ${fcmResult.error}`);
509
+ releaseNotification(title, message, data); // free the reserved dedup slot for a retry
504
510
 
505
511
  addNotification(
506
512
  buildNotificationRecord(
@@ -581,6 +587,7 @@ export const POST: RequestHandler = async ({ request }) => {
581
587
  } catch (notificationError) {
582
588
  const notifErrMsg = toErrorMessage(notificationError);
583
589
  console.error(`[notify] APNs delivery failed: ${notifErrMsg}`);
590
+ releaseNotification(title, message, data); // free the reserved dedup slot for a retry
584
591
 
585
592
  addNotification(
586
593
  buildNotificationRecord(canonicalRequestId, title, message, 'failed', data, notifErrMsg)
@@ -0,0 +1,39 @@
1
+ // Viewer-presence endpoint. The dashboard / phone posts a heartbeat so the autopilot
2
+ // engine can push only when the user is AWAY (not foregrounded) — see presence-store.ts.
3
+ // Distinct from raw WebSocket connection count, which the autonomous loop keeps open.
4
+
5
+ import { validateAuth } from '$lib/modules/server/auth';
6
+ import {
7
+ hasEverReported,
8
+ isViewerPresent,
9
+ reportPresence,
10
+ } from '$lib/modules/server/ws/presence-store';
11
+ import { json } from '@sveltejs/kit';
12
+
13
+ import type { RequestHandler } from './$types';
14
+
15
+ export const GET: RequestHandler = ({ request }) => {
16
+ const authError = validateAuth(request);
17
+ if (authError) {
18
+ return authError;
19
+ }
20
+ return json({ everReported: hasEverReported(), present: isViewerPresent() });
21
+ };
22
+
23
+ export const POST: RequestHandler = async ({ request }) => {
24
+ const authError = validateAuth(request);
25
+ if (authError) {
26
+ return authError;
27
+ }
28
+ let body: { state?: unknown };
29
+ try {
30
+ body = (await request.json()) as { state?: unknown };
31
+ } catch {
32
+ return json({ error: 'Invalid JSON body' }, { status: 400 });
33
+ }
34
+ if (body.state !== 'foreground' && body.state !== 'background') {
35
+ return json({ error: "state must be 'foreground' or 'background'" }, { status: 400 });
36
+ }
37
+ reportPresence(body.state);
38
+ return json({ everReported: hasEverReported(), present: isViewerPresent(), state: body.state });
39
+ };