@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
@@ -2,6 +2,7 @@ import type {
2
2
  ConversationMessage,
3
3
  PtyManagedTerminal as ManagedTerminal,
4
4
  PtyOutputBuffer as OutputBuffer,
5
+ SeqRingEntry,
5
6
  TerminalRecord,
6
7
  } from '$lib/types';
7
8
  import type WebSocket from 'ws';
@@ -18,8 +19,10 @@ import {
18
19
  readOnlySourceForCommand,
19
20
  } from '../sessions/provider-paths';
20
21
  import { broadcastEvent } from '../ws/server.js';
22
+ import { withAgentPermissionMode } from './agent-launch.js';
21
23
  import { HolderClient } from './holder-client';
22
24
  import { openCodeWatcher } from './opencode-watcher';
25
+ import { TerminalEmulator } from './terminal-emulator';
23
26
  import { terminalStore } from './terminal-store';
24
27
 
25
28
  export type { ManagedTerminal };
@@ -31,6 +34,16 @@ export type { ManagedTerminal };
31
34
  const MAX_SCROLLBACK_BYTES = 512 * 1024; // 512 KB cached scrollback cap
32
35
  const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024; // 1 MB per client
33
36
  const SCROLLBACK_CHUNK_SIZE = 50 * 1024; // 50 KB per chunk
37
+ const SEQ_RING_MAX_ENTRIES = 2000; // bounded replay ring (~2-10 MB of recent output)
38
+ // Server-side emulator + snapshot-on-join is on unless explicitly disabled
39
+ // (SHOOTER_SNAPSHOT_FALLBACK=raw reverts to legacy raw-scrollback replay).
40
+ const SNAPSHOT_ENABLED = process.env.SHOOTER_SNAPSHOT_FALLBACK !== 'raw';
41
+ // Phase 2 backpressure convergence: when a client falls behind we stop dropping
42
+ // bytes silently and instead resnapshot it to the current screen once its socket
43
+ // drains below the low-water mark (or after a hard timeout if it never drains).
44
+ const RESNAPSHOT_LOW_WATER_BYTES = MAX_OUTPUT_BUFFER_BYTES / 4;
45
+ const RESNAPSHOT_POLL_MS = 100;
46
+ const RESNAPSHOT_MAX_WAIT_MS = 10_000;
34
47
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
35
48
  const EXITED_TTL_MS = 60 * 60 * 1000; // 1 hour
36
49
  const MAX_EXITED_TERMINALS = 10;
@@ -47,6 +60,10 @@ const __dirname = path.dirname(__filename);
47
60
 
48
61
  class PtyManager {
49
62
  private cleanupTimer: null | ReturnType<typeof setInterval> = null;
63
+ // Clients currently converging via a resnapshot (Phase 2). While pending, a
64
+ // client receives no normal output frames — the forthcoming snapshot brings
65
+ // it to the current screen. WeakSet so disconnected sockets drop out on GC.
66
+ private resnapshotPending = new WeakSet<WebSocket>();
50
67
  private terminals = new Map<string, ManagedTerminal>();
51
68
 
52
69
  constructor() {
@@ -60,25 +77,63 @@ class PtyManager {
60
77
  // persists to SQLite
61
78
  // -----------------------------------------------------------------------
62
79
 
63
- attach(id: string, ws: WebSocket): boolean {
80
+ attach(id: string, ws: WebSocket, opts?: { lastSeq?: number; snapshot?: boolean }): boolean {
64
81
  const terminal = this.terminals.get(id);
65
82
  if (!terminal) {
66
83
  return false;
67
84
  }
68
85
 
69
- terminal.clients.add(ws);
70
86
  terminal.outputBuffers.set(ws, { data: [], size: 0 });
71
87
 
72
- // Send cached scrollback in chunks
88
+ const wantsSnapshot = opts?.snapshot === true;
89
+ const lastSeq = opts?.lastSeq ?? 0;
90
+
91
+ // Reconnect resume (Phase 2): the client already applied output up to
92
+ // lastSeq. If those missing frames are still in the ring, replay just the
93
+ // gap and go live — a seamless catch-up with no screen flash. This branch
94
+ // is fully synchronous, so no live frame can interleave between computing
95
+ // the gap and registering the client (no missed frames). Falls through to a
96
+ // fresh snapshot/scrollback when the gap predates the ring (getSeqRingFrom
97
+ // returns null) or the seq counter reset across a server restart.
98
+ if (wantsSnapshot && lastSeq > 0) {
99
+ const gap = this.getSeqRingFrom(id, lastSeq);
100
+ if (gap !== null) {
101
+ for (const entry of gap) {
102
+ this.safeSend(ws, JSON.stringify({ data: entry.data, seq: entry.seq, type: 'output' }));
103
+ }
104
+ terminal.clients.add(ws);
105
+ return true;
106
+ }
107
+ }
108
+
109
+ if (wantsSnapshot && terminal.emulator) {
110
+ // Snapshot-capable client: send the current-screen snapshot FIRST, then
111
+ // start the live tail (add to clients). Adding to clients only after the
112
+ // snapshot is sent guarantees no live frame precedes or duplicates it —
113
+ // the emulator already includes any output produced while snapshotting,
114
+ // and the snapshot's seq lets the client drop already-included frames.
115
+ void this.snapshotAndSend(terminal, ws).then((ok) => {
116
+ if (ws.readyState !== 1 /* OPEN */) {
117
+ return;
118
+ }
119
+ if (!ok) {
120
+ // Snapshot failed — fall back to the legacy raw scrollback replay.
121
+ terminal.clients.add(ws);
122
+ void this.sendScrollback(terminal, ws);
123
+ return;
124
+ }
125
+ terminal.clients.add(ws);
126
+ });
127
+ return true;
128
+ }
129
+
130
+ terminal.clients.add(ws);
131
+ // Send cached scrollback in chunks (legacy / non-snapshot-capable clients).
73
132
  void this.sendScrollback(terminal, ws);
74
133
 
75
134
  return true;
76
135
  }
77
136
 
78
- // -----------------------------------------------------------------------
79
- // reconnectAll — recover persisted terminals on server startup
80
- // -----------------------------------------------------------------------
81
-
82
137
  cleanup(): void {
83
138
  const now = Date.now();
84
139
  const exited: { exitedAt: number; id: string }[] = [];
@@ -120,7 +175,7 @@ class PtyManager {
120
175
  }
121
176
 
122
177
  // -----------------------------------------------------------------------
123
- // disconnectAllgraceful shutdown: disconnect clients, keep holders alive
178
+ // reconnectAllrecover persisted terminals on server startup
124
179
  // -----------------------------------------------------------------------
125
180
 
126
181
  async create(
@@ -134,8 +189,17 @@ class PtyManager {
134
189
  const socketPath = `/tmp/shooter-term-${id}.sock`;
135
190
  const holderScript = resolveHolderPath();
136
191
 
192
+ // Inject the configured default --permission-mode for claude (SHOOTER_AGENT_PERMISSION_MODE),
193
+ // so a managed agent can act instead of being auto-denied by a restrictive global config.
194
+ // No-op unless set; never overrides an explicit flag. Persisted so reconnect keeps it.
195
+ const launchArgs = withAgentPermissionMode(
196
+ command,
197
+ args,
198
+ process.env.SHOOTER_AGENT_PERMISSION_MODE
199
+ );
200
+
137
201
  // Fork the holder process as detached so it survives server restarts
138
- const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...args];
202
+ const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...launchArgs];
139
203
  const holder: ChildProcess = fork(holderScript, holderArgs, {
140
204
  detached: true,
141
205
  stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
@@ -179,13 +243,14 @@ class PtyManager {
179
243
 
180
244
  const now = new Date();
181
245
  const terminal: ManagedTerminal = {
182
- args,
246
+ args: launchArgs,
183
247
  clients: new Set(),
184
248
  cols,
185
249
  command,
186
250
  createdAt: now,
187
251
  currentCwd: null,
188
252
  cwd,
253
+ emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(cols, rows) : null,
189
254
  exitCode: connectResult.exitCode,
190
255
  exitedAt: null,
191
256
  holderPid,
@@ -199,6 +264,8 @@ class PtyManager {
199
264
  pty: client,
200
265
  rows,
201
266
  scrollback: connectResult.scrollback,
267
+ seqCounter: 0,
268
+ seqRing: [],
202
269
  sessionFile: null,
203
270
  socketPath,
204
271
  status: connectResult.exited ? 'exited' : 'running',
@@ -210,7 +277,7 @@ class PtyManager {
210
277
 
211
278
  // Persist to SQLite
212
279
  terminalStore.insert({
213
- args: JSON.stringify(args),
280
+ args: JSON.stringify(launchArgs),
214
281
  cols,
215
282
  command,
216
283
  createdAt: now.toISOString(),
@@ -242,7 +309,7 @@ class PtyManager {
242
309
  }
243
310
 
244
311
  // -----------------------------------------------------------------------
245
- // get
312
+ // disconnectAll — graceful shutdown: disconnect clients, keep holders alive
246
313
  // -----------------------------------------------------------------------
247
314
 
248
315
  destroy(): void {
@@ -291,8 +358,7 @@ class PtyManager {
291
358
  }
292
359
 
293
360
  // -----------------------------------------------------------------------
294
- // list — running first, then recently exited, each group sorted by
295
- // createdAt descending
361
+ // get
296
362
  // -----------------------------------------------------------------------
297
363
 
298
364
  detach(id: string, ws: WebSocket): boolean {
@@ -307,7 +373,8 @@ class PtyManager {
307
373
  }
308
374
 
309
375
  // -----------------------------------------------------------------------
310
- // killroute through holder: SIGTERM, then SIGKILL after 5 s
376
+ // listrunning first, then recently exited, each group sorted by
377
+ // createdAt descending
311
378
  // -----------------------------------------------------------------------
312
379
 
313
380
  disconnectAll(): void {
@@ -342,7 +409,7 @@ class PtyManager {
342
409
  }
343
410
 
344
411
  // -----------------------------------------------------------------------
345
- // removeremove an exited terminal from the map
412
+ // killroute through holder: SIGTERM, then SIGKILL after 5 s
346
413
  // -----------------------------------------------------------------------
347
414
 
348
415
  get(id: string): ManagedTerminal | null {
@@ -350,7 +417,7 @@ class PtyManager {
350
417
  }
351
418
 
352
419
  // -----------------------------------------------------------------------
353
- // resize
420
+ // remove — remove an exited terminal from the map
354
421
  // -----------------------------------------------------------------------
355
422
 
356
423
  getScrollback(id: string): null | string {
@@ -363,9 +430,45 @@ class PtyManager {
363
430
  }
364
431
 
365
432
  // -----------------------------------------------------------------------
366
- // attach — register a WebSocket client and replay scrollback
433
+ // resize
367
434
  // -----------------------------------------------------------------------
368
435
 
436
+ /** Current highest assigned seq for a terminal, or null if unknown. */
437
+ getSeqCounter(id: string): null | number {
438
+ return this.terminals.get(id)?.seqCounter ?? null;
439
+ }
440
+
441
+ /**
442
+ * Return the ring entries with seq > afterSeq, in order. Returns an empty
443
+ * array when the caller is already current, or null when the gap is
444
+ * unresolvable from the ring (caller must take a full snapshot). Unresolvable
445
+ * means any of:
446
+ * - afterSeq > seqCounter: the caller claims a seq we never produced — this
447
+ * happens when the seq counter reset across a server restart (the client
448
+ * is from a previous terminal lifetime), so its content is unrelated.
449
+ * - ring empty but afterSeq > 0: nothing buffered to bridge the gap.
450
+ * - afterSeq predates the oldest retained entry: the gap aged out.
451
+ */
452
+ getSeqRingFrom(id: string, afterSeq: number): null | readonly SeqRingEntry[] {
453
+ const terminal = this.terminals.get(id);
454
+ if (!terminal) {
455
+ return null;
456
+ }
457
+ if (afterSeq > terminal.seqCounter) {
458
+ return null; // counter reset (restart) or client ahead of us — snapshot
459
+ }
460
+ const ring = terminal.seqRing;
461
+ if (ring.length === 0) {
462
+ // Caught up (afterSeq 0, nothing produced) vs. an impossible-to-bridge
463
+ // positive gap with no buffered frames.
464
+ return afterSeq <= 0 ? [] : null;
465
+ }
466
+ if (afterSeq < ring[0].seq - 1) {
467
+ return null; // gap predates the ring — caller must send a full snapshot
468
+ }
469
+ return ring.filter((e) => e.seq > afterSeq);
470
+ }
471
+
369
472
  kill(id: string): boolean {
370
473
  const terminal = this.terminals.get(id);
371
474
  if (!terminal) {
@@ -406,7 +509,7 @@ class PtyManager {
406
509
  }
407
510
 
408
511
  // -----------------------------------------------------------------------
409
- // detachremove a WebSocket client
512
+ // attachregister a WebSocket client and replay scrollback
410
513
  // -----------------------------------------------------------------------
411
514
 
412
515
  list(): ManagedTerminal[] {
@@ -428,7 +531,7 @@ class PtyManager {
428
531
  }
429
532
 
430
533
  // -----------------------------------------------------------------------
431
- // getScrollbackreturn raw scrollback data for replay
534
+ // detachremove a WebSocket client
432
535
  // -----------------------------------------------------------------------
433
536
 
434
537
  async reconnectAll(): Promise<void> {
@@ -452,8 +555,7 @@ class PtyManager {
452
555
  }
453
556
 
454
557
  // -----------------------------------------------------------------------
455
- // cleanupevict exited terminals older than 1 hour, cap at 10 exited;
456
- // also clean up old SQLite records
558
+ // getScrollbackreturn raw scrollback data for replay
457
559
  // -----------------------------------------------------------------------
458
560
 
459
561
  remove(id: string): boolean {
@@ -470,7 +572,8 @@ class PtyManager {
470
572
  }
471
573
 
472
574
  // -----------------------------------------------------------------------
473
- // destroyemergency forced kill (kills holder processes too)
575
+ // cleanupevict exited terminals older than 1 hour, cap at 10 exited;
576
+ // also clean up old SQLite records
474
577
  // -----------------------------------------------------------------------
475
578
 
476
579
  resize(id: string, cols: number, rows: number): boolean {
@@ -483,6 +586,7 @@ class PtyManager {
483
586
  terminal.pty.resize(cols, rows);
484
587
  terminal.cols = cols;
485
588
  terminal.rows = rows;
589
+ terminal.emulator?.resize(cols, rows);
486
590
  // Broadcast the new PTY size so attached clients (e.g. view-only
487
591
  // guests) can follow the terminal dimensions.
488
592
  const msg = JSON.stringify({ cols, rows, type: 'resize' });
@@ -495,6 +599,41 @@ class PtyManager {
495
599
  }
496
600
  }
497
601
 
602
+ // -----------------------------------------------------------------------
603
+ // destroy — emergency forced kill (kills holder processes too)
604
+ // -----------------------------------------------------------------------
605
+
606
+ /**
607
+ * Compute the current-screen snapshot from the emulator and send it as a
608
+ * single {type:'snapshot'} frame stamped with the current seq. Returns false
609
+ * if there is no emulator, the socket closed, or serialization failed.
610
+ * Reused by Phase 2 to resnapshot a client after a backpressure gap.
611
+ */
612
+ async snapshotAndSend(terminal: ManagedTerminal, ws: WebSocket): Promise<boolean> {
613
+ if (!terminal.emulator) {
614
+ return false;
615
+ }
616
+ try {
617
+ const snap = await terminal.emulator.snapshot();
618
+ if (ws.readyState !== 1 /* OPEN */) {
619
+ return false;
620
+ }
621
+ this.safeSend(
622
+ ws,
623
+ JSON.stringify({
624
+ cols: snap.cols,
625
+ data: snap.data,
626
+ rows: snap.rows,
627
+ seq: terminal.seqCounter,
628
+ type: 'snapshot',
629
+ })
630
+ );
631
+ return true;
632
+ } catch {
633
+ return false;
634
+ }
635
+ }
636
+
498
637
  // -----------------------------------------------------------------------
499
638
  // Private: reconnectOne — reconnect to a single persisted terminal
500
639
  // -----------------------------------------------------------------------
@@ -516,17 +655,92 @@ class PtyManager {
516
655
  }
517
656
  }
518
657
 
658
+ /**
659
+ * Assign the next sequence number to an output chunk and append it to the
660
+ * bounded replay ring. Returns the new seq. Phase 2 uses the ring to replay
661
+ * the gap to a reconnecting client without a full snapshot.
662
+ */
663
+ private appendSeqRing(terminal: ManagedTerminal, data: string): number {
664
+ const seq = terminal.seqCounter + 1;
665
+ terminal.seqRing.push({ data, seq });
666
+ if (terminal.seqRing.length > SEQ_RING_MAX_ENTRIES) {
667
+ terminal.seqRing.shift();
668
+ }
669
+ terminal.seqCounter = seq;
670
+ return seq;
671
+ }
672
+
673
+ /**
674
+ * Mark a client for convergence (Phase 2). Its queued output is discarded and
675
+ * further live frames are withheld until its socket drains below the low-water
676
+ * mark (or a hard timeout elapses), at which point a fresh snapshot resets it
677
+ * to the current screen. This replaces silent byte-dropping so a slow or
678
+ * throttled client can never diverge permanently (G1).
679
+ */
680
+ private beginResnapshot(terminal: ManagedTerminal, ws: WebSocket): void {
681
+ if (this.resnapshotPending.has(ws)) {
682
+ return; // already converging
683
+ }
684
+ this.resnapshotPending.add(ws);
685
+
686
+ // Drop the now-stale queue; the snapshot supersedes it.
687
+ const buffer = terminal.outputBuffers.get(ws);
688
+ if (buffer) {
689
+ buffer.data.length = 0;
690
+ buffer.size = 0;
691
+ }
692
+
693
+ // Signal the gap so the client can show a "resyncing" state; the snapshot
694
+ // that follows performs the actual screen reset.
695
+ this.safeSend(ws, JSON.stringify({ bytes: 0, type: 'output-dropped' }));
696
+
697
+ const startedAt = Date.now();
698
+ const poll = (): void => {
699
+ if (!this.resnapshotPending.has(ws)) {
700
+ return; // resolved or cancelled elsewhere
701
+ }
702
+ // Give up if the client left or the terminal/emulator went away meanwhile.
703
+ if (ws.readyState !== 1 /* OPEN */ || !terminal.emulator || !terminal.clients.has(ws)) {
704
+ this.resnapshotPending.delete(ws);
705
+ return;
706
+ }
707
+ const drained = ws.bufferedAmount <= RESNAPSHOT_LOW_WATER_BYTES;
708
+ const timedOut = Date.now() - startedAt > RESNAPSHOT_MAX_WAIT_MS;
709
+ if (drained || timedOut) {
710
+ // snapshotAndSend reads seqCounter after awaiting the emulator, so the
711
+ // client's lastSeq jumps to "now" and the withheld frames (already in
712
+ // the snapshot) are never replayed. Clear pending only after it sends.
713
+ void this.snapshotAndSend(terminal, ws).finally(() => {
714
+ this.resnapshotPending.delete(ws);
715
+ });
716
+ return;
717
+ }
718
+ setTimeout(poll, RESNAPSHOT_POLL_MS);
719
+ };
720
+ setTimeout(poll, RESNAPSHOT_POLL_MS);
721
+ }
722
+
519
723
  // -----------------------------------------------------------------------
520
724
  // Private: handleReconnectFailure — handle failed reconnection
521
725
  // -----------------------------------------------------------------------
522
726
 
523
727
  private broadcastOutput(terminal: ManagedTerminal, data: string): void {
524
- const msg = JSON.stringify({ data, type: 'output' });
728
+ // Assign a sequence number and append to the replay ring before broadcasting.
729
+ const seq = this.appendSeqRing(terminal, data);
730
+ const msg = JSON.stringify({ data, seq, type: 'output' });
731
+
732
+ // Fallback mode (SHOOTER_SNAPSHOT_FALLBACK=raw): no emulator, so a slow
733
+ // client cannot be resnapshotted — keep the legacy drop-oldest behaviour.
734
+ if (!terminal.emulator) {
735
+ this.broadcastOutputLegacy(terminal, msg);
736
+ return;
737
+ }
525
738
 
739
+ const msgSize = Buffer.byteLength(msg, 'utf8');
526
740
  for (const ws of terminal.clients) {
527
- // Skip if WebSocket has too much queued already
528
- if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
529
- this.safeSend(ws, JSON.stringify({ bytes: data.length, type: 'output-dropped' }));
741
+ // A converging client receives no live frames until its snapshot is sent;
742
+ // buffering them here would just re-overflow the socket.
743
+ if (this.resnapshotPending.has(ws)) {
530
744
  continue;
531
745
  }
532
746
 
@@ -535,9 +749,41 @@ class PtyManager {
535
749
  continue;
536
750
  }
537
751
 
538
- const msgSize = Buffer.byteLength(msg, 'utf8');
752
+ // Socket- or buffer-level overflow ⇒ the client cannot keep up. Converge
753
+ // it to the current screen via resnapshot instead of dropping bytes (G1).
754
+ if (
755
+ ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES ||
756
+ buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES
757
+ ) {
758
+ this.beginResnapshot(terminal, ws);
759
+ continue;
760
+ }
761
+
762
+ buffer.data.push(msg);
763
+ buffer.size += msgSize;
764
+ this.flushOutputBuffer(ws, buffer);
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Legacy broadcast path used only when the emulator is disabled
770
+ * (SHOOTER_SNAPSHOT_FALLBACK=raw). Drops the oldest buffered output to make
771
+ * room and notifies the client; there is no snapshot to converge it to.
772
+ */
773
+ private broadcastOutputLegacy(terminal: ManagedTerminal, msg: string): void {
774
+ const msgSize = Buffer.byteLength(msg, 'utf8');
775
+ for (const ws of terminal.clients) {
776
+ // Skip if the socket has too much queued already.
777
+ if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
778
+ this.safeSend(ws, JSON.stringify({ bytes: msgSize, type: 'output-dropped' }));
779
+ continue;
780
+ }
781
+
782
+ const buffer = terminal.outputBuffers.get(ws);
783
+ if (!buffer) {
784
+ continue;
785
+ }
539
786
 
540
- // Check backpressure: if buffer exceeds limit, drop oldest data
541
787
  if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
542
788
  let droppedBytes = 0;
543
789
  while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
@@ -548,21 +794,13 @@ class PtyManager {
548
794
  droppedBytes += droppedSize;
549
795
  }
550
796
  }
551
-
552
- // Notify client of dropped output
553
797
  if (droppedBytes > 0) {
554
- const dropMsg = JSON.stringify({
555
- bytes: droppedBytes,
556
- type: 'output-dropped',
557
- });
558
- this.safeSend(ws, dropMsg);
798
+ this.safeSend(ws, JSON.stringify({ bytes: droppedBytes, type: 'output-dropped' }));
559
799
  }
560
800
  }
561
801
 
562
- // Buffer the message and attempt to send
563
802
  buffer.data.push(msg);
564
803
  buffer.size += msgSize;
565
-
566
804
  this.flushOutputBuffer(ws, buffer);
567
805
  }
568
806
  }
@@ -599,6 +837,10 @@ class PtyManager {
599
837
  terminal.openCodeNoopCb = null;
600
838
  }
601
839
 
840
+ // Dispose the server-side emulator (frees its parser/buffer state)
841
+ terminal.emulator?.dispose();
842
+ terminal.emulator = null;
843
+
602
844
  // Disconnect from holder (but don't kill it — it may already be gone)
603
845
  terminal.pty.disconnect();
604
846
 
@@ -727,6 +969,7 @@ class PtyManager {
727
969
  createdAt: new Date(record.createdAt),
728
970
  currentCwd: null,
729
971
  cwd: record.cwd,
972
+ emulator: SNAPSHOT_ENABLED ? new TerminalEmulator(record.cols, record.rows) : null,
730
973
  exitCode: connectResult.exitCode,
731
974
  exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
732
975
  holderPid: record.holderPid ?? 0,
@@ -740,12 +983,22 @@ class PtyManager {
740
983
  pty: client,
741
984
  rows: record.rows,
742
985
  scrollback: connectResult.scrollback,
986
+ seqCounter: 0,
987
+ seqRing: [],
743
988
  sessionFile: record.sessionFile ?? null,
744
989
  socketPath: record.socketPath,
745
990
  status: connectResult.exited ? 'exited' : 'running',
746
991
  watcherOffset: 0,
747
992
  };
748
993
 
994
+ // Seed the fresh emulator with the holder's retained scrollback so a
995
+ // snapshot taken right after a server restart reflects the screen as it was,
996
+ // not a blank buffer. The scrollback is raw PTY bytes (escape sequences
997
+ // included), which the emulator parses into the current screen state.
998
+ if (terminal.emulator && connectResult.scrollback.length > 0) {
999
+ terminal.emulator.write(connectResult.scrollback);
1000
+ }
1001
+
749
1002
  // If the PTY already exited, update SQLite and add to Map for visibility
750
1003
  if (connectResult.exited) {
751
1004
  terminal.exitedAt = terminal.exitedAt ?? new Date();
@@ -1083,6 +1336,7 @@ class PtyManager {
1083
1336
  });
1084
1337
 
1085
1338
  client.onOutput((data: string) => {
1339
+ terminal.emulator?.write(data);
1086
1340
  this.appendScrollback(terminal, data);
1087
1341
  this.broadcastOutput(terminal, data);
1088
1342
  });
@@ -26,6 +26,10 @@ class SessionWatcher {
26
26
  string,
27
27
  Map<string, { parts: MessagePart[]; timestamp: string }>
28
28
  >();
29
+ // Last read offset per file, retained ACROSS stop() so a re-subscribe resumes where it left off
30
+ // instead of jumping to the current end of file (which would skip any lines — e.g. an agent-idle
31
+ // marker — written while no subscriber happened to be attached).
32
+ private lastOffsetPerFile = new Map<string, number>();
29
33
  // Buffer for incomplete trailing lines (no terminating newline yet)
30
34
  private lineBufferPerFile = new Map<string, string>();
31
35
  // Track message index per file for generating fallback IDs
@@ -107,6 +111,8 @@ class SessionWatcher {
107
111
  }
108
112
 
109
113
  void watched.watcher.close();
114
+ // Remember where we stopped reading so a future re-subscribe resumes from here, not from EOF.
115
+ this.lastOffsetPerFile.set(filePath, watched.offset);
110
116
  this.watchedFiles.delete(filePath);
111
117
  this.assistantTurnsPerFile.delete(filePath);
112
118
  this.messageIndexPerFile.delete(filePath);
@@ -149,8 +155,12 @@ class SessionWatcher {
149
155
  };
150
156
  }
151
157
 
152
- // Initialize tracking state for this file
153
- const initialOffset = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
158
+ // Initialize tracking state for this file. Resume from the last read offset if we watched this
159
+ // file before (so content written during the gap is not skipped); otherwise start at EOF.
160
+ const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
161
+ const remembered = this.lastOffsetPerFile.get(filePath);
162
+ this.lastOffsetPerFile.delete(filePath);
163
+ const initialOffset = remembered !== undefined ? Math.min(remembered, fileSize) : fileSize;
154
164
  this.assistantTurnsPerFile.set(filePath, new Map());
155
165
  this.messageIndexPerFile.set(filePath, 0);
156
166
  this.lineBufferPerFile.set(filePath, '');