@juspay/shooter 1.21.0 → 1.22.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 (207) 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/BfbPKMXz.js +3 -0
  6. package/build/client/_app/immutable/chunks/BfbPKMXz.js.br +0 -0
  7. package/build/client/_app/immutable/chunks/BfbPKMXz.js.gz +0 -0
  8. package/build/client/_app/immutable/chunks/C4Hns_Wl.js +1 -0
  9. package/build/client/_app/immutable/chunks/C4Hns_Wl.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/C4Hns_Wl.js.gz +0 -0
  11. package/build/client/_app/immutable/chunks/{BmfLecb1.js → CZg4kn4E.js} +1 -1
  12. package/build/client/_app/immutable/chunks/CZg4kn4E.js.br +0 -0
  13. package/build/client/_app/immutable/chunks/CZg4kn4E.js.gz +0 -0
  14. package/build/client/_app/immutable/chunks/{EqMAkEha.js → DhK7PwI_.js} +1 -1
  15. package/build/client/_app/immutable/chunks/DhK7PwI_.js.br +0 -0
  16. package/build/client/_app/immutable/chunks/DhK7PwI_.js.gz +0 -0
  17. package/build/client/_app/immutable/entry/{app.CeSxgGat.js → app.CTqz33nP.js} +2 -2
  18. package/build/client/_app/immutable/entry/app.CTqz33nP.js.br +0 -0
  19. package/build/client/_app/immutable/entry/app.CTqz33nP.js.gz +0 -0
  20. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js +1 -0
  21. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.br +2 -0
  22. package/build/client/_app/immutable/entry/start.Dj-Kvgwo.js.gz +0 -0
  23. package/build/client/_app/immutable/nodes/{0.oaPwxh1O.js → 0.Qn7Ktiht.js} +1 -1
  24. package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.br +0 -0
  25. package/build/client/_app/immutable/nodes/0.Qn7Ktiht.js.gz +0 -0
  26. package/build/client/_app/immutable/nodes/{1.DMPyoM-M.js → 1.BxWOfNlo.js} +1 -1
  27. package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.br +0 -0
  28. package/build/client/_app/immutable/nodes/1.BxWOfNlo.js.gz +0 -0
  29. package/build/client/_app/immutable/nodes/{10.Cbm7nQKK.js → 10.BGPYD1s1.js} +1 -1
  30. package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.br +0 -0
  31. package/build/client/_app/immutable/nodes/10.BGPYD1s1.js.gz +0 -0
  32. package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js → 11.BxY1PUjC.js} +1 -1
  33. package/build/client/_app/immutable/nodes/11.BxY1PUjC.js.br +0 -0
  34. package/build/client/_app/immutable/nodes/{11.CKmZjP_a.js.gz → 11.BxY1PUjC.js.gz} +0 -0
  35. package/build/client/_app/immutable/nodes/2.Bc2qALkX.js +23 -0
  36. package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.br +0 -0
  37. package/build/client/_app/immutable/nodes/2.Bc2qALkX.js.gz +0 -0
  38. package/build/client/_app/immutable/nodes/{3.BgLpGnzb.js → 3.N2-A8noI.js} +1 -1
  39. package/build/client/_app/immutable/nodes/3.N2-A8noI.js.br +0 -0
  40. package/build/client/_app/immutable/nodes/3.N2-A8noI.js.gz +0 -0
  41. package/build/client/_app/immutable/nodes/{5.Avc1-gVb.js → 5.DziEu9rx.js} +1 -1
  42. package/build/client/_app/immutable/nodes/5.DziEu9rx.js.br +0 -0
  43. package/build/client/_app/immutable/nodes/5.DziEu9rx.js.gz +0 -0
  44. package/build/client/_app/immutable/nodes/{6.Dw2wEssJ.js → 6.BWF9Qx6F.js} +1 -1
  45. package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.br +0 -0
  46. package/build/client/_app/immutable/nodes/6.BWF9Qx6F.js.gz +0 -0
  47. package/build/client/_app/immutable/nodes/{7.DwKZjoBg.js → 7.DHuDIdpz.js} +1 -1
  48. package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.br +0 -0
  49. package/build/client/_app/immutable/nodes/7.DHuDIdpz.js.gz +0 -0
  50. package/build/client/_app/immutable/nodes/{8.ZUAI6g5E.js → 8.D0Ijt9Vv.js} +1 -1
  51. package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.br +0 -0
  52. package/build/client/_app/immutable/nodes/8.D0Ijt9Vv.js.gz +0 -0
  53. package/build/client/_app/immutable/nodes/{9.I_KGXPwB.js → 9.2Piwo35J.js} +1 -1
  54. package/build/client/_app/immutable/nodes/9.2Piwo35J.js.br +0 -0
  55. package/build/client/_app/immutable/nodes/9.2Piwo35J.js.gz +0 -0
  56. package/build/client/_app/version.json +1 -1
  57. package/build/client/_app/version.json.br +0 -0
  58. package/build/client/_app/version.json.gz +0 -0
  59. package/build/pty-holder.cjs +6 -0
  60. package/build/server/chunks/{0-vrTNAfZB.js → 0-CVGsyVKN.js} +2 -2
  61. package/build/server/chunks/{0-vrTNAfZB.js.map → 0-CVGsyVKN.js.map} +1 -1
  62. package/build/server/chunks/{1-nbr-bOoF.js → 1-BAlAsKdp.js} +2 -2
  63. package/build/server/chunks/{1-nbr-bOoF.js.map → 1-BAlAsKdp.js.map} +1 -1
  64. package/build/server/chunks/{10-ChyvvJ6w.js → 10-BUCX7Aqz.js} +2 -2
  65. package/build/server/chunks/{10-ChyvvJ6w.js.map → 10-BUCX7Aqz.js.map} +1 -1
  66. package/build/server/chunks/{11-6ZAjL3uU.js → 11-DHPvc2yA.js} +2 -2
  67. package/build/server/chunks/{11-6ZAjL3uU.js.map → 11-DHPvc2yA.js.map} +1 -1
  68. package/build/server/chunks/{2-DWFRVDWJ.js → 2-DLOMdCHW.js} +4 -4
  69. package/build/server/chunks/{2-DWFRVDWJ.js.map → 2-DLOMdCHW.js.map} +1 -1
  70. package/build/server/chunks/{3-CKANM_WM.js → 3-DCf69LYo.js} +2 -2
  71. package/build/server/chunks/{3-CKANM_WM.js.map → 3-DCf69LYo.js.map} +1 -1
  72. package/build/server/chunks/{5-BxVjs2qi.js → 5-D-Uv1voC.js} +2 -2
  73. package/build/server/chunks/{5-BxVjs2qi.js.map → 5-D-Uv1voC.js.map} +1 -1
  74. package/build/server/chunks/{6-Cbf1AAMQ.js → 6-DUrC2Naz.js} +2 -2
  75. package/build/server/chunks/{6-Cbf1AAMQ.js.map → 6-DUrC2Naz.js.map} +1 -1
  76. package/build/server/chunks/{7-CMK2quEf.js → 7-TXwjMHt2.js} +2 -2
  77. package/build/server/chunks/{7-CMK2quEf.js.map → 7-TXwjMHt2.js.map} +1 -1
  78. package/build/server/chunks/{8-DhdfkfDM.js → 8-D2X_jBsT.js} +2 -2
  79. package/build/server/chunks/{8-DhdfkfDM.js.map → 8-D2X_jBsT.js.map} +1 -1
  80. package/build/server/chunks/{9-CPpxtRM5.js → 9-DK0hH5Xa.js} +2 -2
  81. package/build/server/chunks/{9-CPpxtRM5.js.map → 9-DK0hH5Xa.js.map} +1 -1
  82. package/build/server/chunks/_page.svelte-8OFzwdNA.js +758 -0
  83. package/build/server/chunks/_page.svelte-8OFzwdNA.js.map +1 -0
  84. package/build/server/chunks/{_server.ts-BWVlO8iV.js → _server.ts-05JJOdcX.js} +15 -12
  85. package/build/server/chunks/_server.ts-05JJOdcX.js.map +1 -0
  86. package/build/server/chunks/{_server.ts-CC2K8-L2.js → _server.ts-B54Pvhgc.js} +3 -2
  87. package/build/server/chunks/_server.ts-B54Pvhgc.js.map +1 -0
  88. package/build/server/chunks/{_server.ts-BevnuePu.js → _server.ts-BCljU9Sg.js} +7 -3
  89. package/build/server/chunks/_server.ts-BCljU9Sg.js.map +1 -0
  90. package/build/server/chunks/{_server.ts-D-vgx5UZ.js → _server.ts-BTmknWpO.js} +2 -2
  91. package/build/server/chunks/{_server.ts-D-vgx5UZ.js.map → _server.ts-BTmknWpO.js.map} +1 -1
  92. package/build/server/chunks/{_server.ts-tChyh9FX.js → _server.ts-BXhmLZwN.js} +4 -2
  93. package/build/server/chunks/{_server.ts-tChyh9FX.js.map → _server.ts-BXhmLZwN.js.map} +1 -1
  94. package/build/server/chunks/{_server.ts-CvJKTS3Z.js → _server.ts-BbRSpB74.js} +4 -2
  95. package/build/server/chunks/{_server.ts-CvJKTS3Z.js.map → _server.ts-BbRSpB74.js.map} +1 -1
  96. package/build/server/chunks/{_server.ts-Dp-hXW_I.js → _server.ts-Bol54_Qo.js} +3 -2
  97. package/build/server/chunks/_server.ts-Bol54_Qo.js.map +1 -0
  98. package/build/server/chunks/{_server.ts-X1R7L_QI.js → _server.ts-C0PO_cAu.js} +3 -2
  99. package/build/server/chunks/{_server.ts-X1R7L_QI.js.map → _server.ts-C0PO_cAu.js.map} +1 -1
  100. package/build/server/chunks/_server.ts-C6NRpe7e.js +33 -0
  101. package/build/server/chunks/_server.ts-C6NRpe7e.js.map +1 -0
  102. package/build/server/chunks/_server.ts-CGqCOCdK.js +53 -0
  103. package/build/server/chunks/_server.ts-CGqCOCdK.js.map +1 -0
  104. package/build/server/chunks/{_server.ts-CA5KUENM.js → _server.ts-CZb-BI5H.js} +3 -2
  105. package/build/server/chunks/_server.ts-CZb-BI5H.js.map +1 -0
  106. package/build/server/chunks/{_server.ts-VzDcFFgy.js → _server.ts-DPHRUFYS.js} +4 -2
  107. package/build/server/chunks/_server.ts-DPHRUFYS.js.map +1 -0
  108. package/build/server/chunks/{_server.ts-D0zRDSx0.js → _server.ts-D_WRex0k.js} +4 -2
  109. package/build/server/chunks/_server.ts-D_WRex0k.js.map +1 -0
  110. package/build/server/chunks/{_server.ts-CD7JP3fz.js → _server.ts-DiBMY7Ho.js} +3 -2
  111. package/build/server/chunks/_server.ts-DiBMY7Ho.js.map +1 -0
  112. package/build/server/chunks/{library-apns-Dl3iRE2h.js → library-apns-D8RPINlv.js} +62 -7
  113. package/build/server/chunks/library-apns-D8RPINlv.js.map +1 -0
  114. package/build/server/chunks/{pending-requests-C9p57WoU.js → pending-requests-8rWjrF6d.js} +3 -2
  115. package/build/server/chunks/pending-requests-8rWjrF6d.js.map +1 -0
  116. package/build/server/chunks/presence-store-Bx_g0-Gd.js +23 -0
  117. package/build/server/chunks/presence-store-Bx_g0-Gd.js.map +1 -0
  118. package/build/server/chunks/{pty-manager-ZqXqa-6A.js → pty-manager-CoWVT56F.js} +26 -5
  119. package/build/server/chunks/pty-manager-CoWVT56F.js.map +1 -0
  120. package/build/server/chunks/shooter-home-4f_HkdGI.js +10 -0
  121. package/build/server/chunks/shooter-home-4f_HkdGI.js.map +1 -0
  122. package/build/server/index.js +1 -1
  123. package/build/server/index.js.map +1 -1
  124. package/build/server/manifest.js +38 -24
  125. package/build/server/manifest.js.map +1 -1
  126. package/package.json +2 -2
  127. package/src/lib/modules/client/common/index.ts +1 -0
  128. package/src/lib/modules/client/common/presence.ts +47 -0
  129. package/src/lib/modules/client/dashboard/AutopilotPanel.svelte +188 -4
  130. package/src/lib/modules/client/dashboard/autopilot-driver.svelte.ts +681 -0
  131. package/src/lib/modules/client/dashboard/decide-injection.ts +127 -0
  132. package/src/lib/modules/client/dashboard/store.svelte.ts +65 -24
  133. package/src/lib/modules/client/neurolink/fetch-proxy.ts +38 -1
  134. package/src/lib/modules/server/apn/apns-payload.ts +50 -0
  135. package/src/lib/modules/server/apn/library-apns.ts +50 -8
  136. package/src/lib/modules/server/apn/pending-requests.ts +3 -1
  137. package/src/lib/modules/server/sessions/autopilot-context.ts +57 -0
  138. package/src/lib/modules/server/sessions/autopilot-engine.ts +148 -43
  139. package/src/lib/modules/server/sessions/litellm-client.ts +90 -34
  140. package/src/lib/modules/server/sessions/next-step-consensus.ts +27 -2
  141. package/src/lib/modules/server/sessions/summary-store.ts +3 -1
  142. package/src/lib/modules/server/terminal/agent-launch.ts +26 -0
  143. package/src/lib/modules/server/terminal/pty-holder.cjs +6 -0
  144. package/src/lib/modules/server/terminal/pty-manager.ts +13 -3
  145. package/src/lib/modules/server/terminal/session-watcher.ts +12 -2
  146. package/src/lib/modules/server/terminal/terminal-store.ts +3 -1
  147. package/src/lib/modules/server/utils/shooter-home.ts +16 -0
  148. package/src/lib/modules/server/ws/presence-store.ts +50 -0
  149. package/src/lib/types/autopilot.ts +65 -0
  150. package/src/routes/api/autopilot/goal/+server.ts +72 -0
  151. package/src/routes/api/neurolink-proxy/+server.ts +8 -2
  152. package/src/routes/api/notify/+server.ts +22 -15
  153. package/src/routes/api/presence/+server.ts +39 -0
  154. package/src/routes/api/ws-status/+server.ts +8 -5
  155. package/build/client/_app/immutable/assets/2.BHi6pjT2.css +0 -1
  156. package/build/client/_app/immutable/assets/2.BHi6pjT2.css.br +0 -0
  157. package/build/client/_app/immutable/assets/2.BHi6pjT2.css.gz +0 -0
  158. package/build/client/_app/immutable/chunks/BmfLecb1.js.br +0 -0
  159. package/build/client/_app/immutable/chunks/BmfLecb1.js.gz +0 -0
  160. package/build/client/_app/immutable/chunks/CRkG7oE4.js +0 -1
  161. package/build/client/_app/immutable/chunks/CRkG7oE4.js.br +0 -0
  162. package/build/client/_app/immutable/chunks/CRkG7oE4.js.gz +0 -0
  163. package/build/client/_app/immutable/chunks/DOEXXmsh.js +0 -3
  164. package/build/client/_app/immutable/chunks/DOEXXmsh.js.br +0 -0
  165. package/build/client/_app/immutable/chunks/DOEXXmsh.js.gz +0 -0
  166. package/build/client/_app/immutable/chunks/EqMAkEha.js.br +0 -0
  167. package/build/client/_app/immutable/chunks/EqMAkEha.js.gz +0 -0
  168. package/build/client/_app/immutable/entry/app.CeSxgGat.js.br +0 -0
  169. package/build/client/_app/immutable/entry/app.CeSxgGat.js.gz +0 -0
  170. package/build/client/_app/immutable/entry/start.DrnJFwxA.js +0 -1
  171. package/build/client/_app/immutable/entry/start.DrnJFwxA.js.br +0 -2
  172. package/build/client/_app/immutable/entry/start.DrnJFwxA.js.gz +0 -0
  173. package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.br +0 -0
  174. package/build/client/_app/immutable/nodes/0.oaPwxh1O.js.gz +0 -0
  175. package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.br +0 -0
  176. package/build/client/_app/immutable/nodes/1.DMPyoM-M.js.gz +0 -0
  177. package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.br +0 -0
  178. package/build/client/_app/immutable/nodes/10.Cbm7nQKK.js.gz +0 -0
  179. package/build/client/_app/immutable/nodes/11.CKmZjP_a.js.br +0 -0
  180. package/build/client/_app/immutable/nodes/2.zlrdNFtH.js +0 -13
  181. package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.br +0 -0
  182. package/build/client/_app/immutable/nodes/2.zlrdNFtH.js.gz +0 -0
  183. package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.br +0 -0
  184. package/build/client/_app/immutable/nodes/3.BgLpGnzb.js.gz +0 -0
  185. package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.br +0 -0
  186. package/build/client/_app/immutable/nodes/5.Avc1-gVb.js.gz +0 -0
  187. package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.br +0 -0
  188. package/build/client/_app/immutable/nodes/6.Dw2wEssJ.js.gz +0 -0
  189. package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.br +0 -0
  190. package/build/client/_app/immutable/nodes/7.DwKZjoBg.js.gz +0 -0
  191. package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.br +0 -0
  192. package/build/client/_app/immutable/nodes/8.ZUAI6g5E.js.gz +0 -0
  193. package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.br +0 -0
  194. package/build/client/_app/immutable/nodes/9.I_KGXPwB.js.gz +0 -0
  195. package/build/server/chunks/_page.svelte-tBuIq8Pg.js +0 -159
  196. package/build/server/chunks/_page.svelte-tBuIq8Pg.js.map +0 -1
  197. package/build/server/chunks/_server.ts-BWVlO8iV.js.map +0 -1
  198. package/build/server/chunks/_server.ts-BevnuePu.js.map +0 -1
  199. package/build/server/chunks/_server.ts-CA5KUENM.js.map +0 -1
  200. package/build/server/chunks/_server.ts-CC2K8-L2.js.map +0 -1
  201. package/build/server/chunks/_server.ts-CD7JP3fz.js.map +0 -1
  202. package/build/server/chunks/_server.ts-D0zRDSx0.js.map +0 -1
  203. package/build/server/chunks/_server.ts-Dp-hXW_I.js.map +0 -1
  204. package/build/server/chunks/_server.ts-VzDcFFgy.js.map +0 -1
  205. package/build/server/chunks/library-apns-Dl3iRE2h.js.map +0 -1
  206. package/build/server/chunks/pending-requests-C9p57WoU.js.map +0 -1
  207. package/build/server/chunks/pty-manager-ZqXqa-6A.js.map +0 -1
@@ -12,11 +12,18 @@
12
12
  import type { AgentProposal, NextStep, SessionSummaryRecord, WireShooterEvent } from '$lib/types';
13
13
 
14
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
15
- import { homedir } from 'os';
16
15
  import { join } from 'path';
17
16
 
18
17
  import { ptyManager } from '../terminal/pty-manager.js';
18
+ import { shooterDataDir } from '../utils/shooter-home.js';
19
19
  import { onShooterEvent } from '../ws/events-handler.js';
20
+ import { isViewerPresent } from '../ws/presence-store.js';
21
+ import {
22
+ buildEngineContext,
23
+ clearEngineGoal,
24
+ getEngineGoal,
25
+ setEngineGoal,
26
+ } from './autopilot-context.js';
20
27
  import { isLiteLLMConfigured, litellmJson } from './litellm-client.js';
21
28
  import { mergeNextStepConsensus } from './next-step-consensus.js';
22
29
  import { summaryStore } from './summary-store.js';
@@ -29,27 +36,57 @@ const MIN_INTERVAL_MS = 30_000; // minimum gap between pipeline runs per session
29
36
  const PERIODIC_EVERY = 20;
30
37
  const ERROR_THRESHOLD = 3;
31
38
  const MAX_EVENTS = 60;
32
- const SUMMARY_MAX_TOKENS = 220;
33
- const STEPS_MAX_TOKENS = 320;
34
- const STATE_FILE = join(homedir(), '.shooter', 'autopilot.json');
35
-
36
- /** Five DISTINCT lenses each a different perspective, so votes mean real agreement. */
39
+ const SUMMARY_MAX_TOKENS = 400;
40
+ const STEPS_MAX_TOKENS = 400;
41
+ // How many of the five lenses run concurrently. Kept BELOW the typical LiteLLM key parallel cap so
42
+ // the engine never saturates the key by itself: firing all five at once exhausted a
43
+ // max_parallel_requests=5 key and 429'd every call (empty consensus, silent stall). Default 3
44
+ // leaves headroom for the summary's retry + other callers sharing the key. Tune per key via env.
45
+ const LENS_CONCURRENCY = Math.max(1, Number(process.env.AUTOPILOT_LENS_CONCURRENCY) || 3);
46
+ // Consensus quorum: how many of the 5 distinct lenses must agree for a step to be non-tentative
47
+ // (and thus auto-injectable). The lenses are DIFFERENT perspectives that phrase the same action
48
+ // differently, so the Jaccard clustering systematically undercounts true agreement (observed: 4/5
49
+ // lenses proposed the same fix but only 2 clustered). 2-of-5 + the confidence floor + the eight
50
+ // driver guards (idle-only, managed-only, rate-limit, dedup, circuit-breaker, command-guard,
51
+ // kill-switch, human-grace) is the practical bar; quorum 3 left the autopilot almost never firing.
52
+ const CONSENSUS_QUORUM = 2;
53
+ const STATE_FILE = join(shooterDataDir(), 'autopilot.json');
54
+
55
+ // The engine does STRUCTURED extraction (one summary + five voting lenses). A reasoning model like
56
+ // `open-large` does this badly: it reasons out loud and ignores response_format, so the JSON never
57
+ // parses and the consensus collapses to tentative garbage. Pin a fast NON-reasoning model for the
58
+ // pipeline, independent of the user's chat model (LITELLM_MODEL). Overridable via AUTOPILOT_MODEL.
59
+ const ENGINE_MODEL = process.env.AUTOPILOT_MODEL?.trim() || 'open-fast';
60
+
61
+ // Lead with the JSON-only contract; the per-lens perspective is a TRAILING modifier so the model
62
+ // doesn't start "thinking" about a role. No copyable placeholder value in the schema — reasoning
63
+ // models echo it verbatim ("a real shell command or instruction" leaked into real consensus).
64
+ const JSON_API_RULES =
65
+ 'You are a JSON API. Output ONLY one JSON object and nothing else — no prose, no reasoning, no ' +
66
+ 'markdown, no code fences. The first character MUST be { and the last MUST be }.';
67
+
68
+ /** Five DISTINCT perspectives — each a different angle, so converging votes mean real agreement. */
37
69
  const LENSES = [
38
- 'You are a BLOCKER-detection agent. Identify what is currently preventing progress or is most likely to fail next.',
39
- 'You are a NEXT-COMMAND planner. Propose the exact shell commands or file edits the agent should run next, in order.',
40
- 'You are a RISK analyst. Identify what could go wrong if the agent continues on its current path, and what to validate first.',
41
- 'You are a VALIDATION agent. Propose how to verify the work so far is correct — the tests, checks, or inspections to run.',
42
- 'You are a PROGRESS agent. Propose the single most direct next step toward completing the session goal.',
70
+ 'what is currently blocking progress, or is most likely to fail next',
71
+ 'the exact shell commands or file edits the agent should run next, in order',
72
+ 'what could go wrong if the agent continues on its current path, and what to validate first',
73
+ 'how to verify the work so far is correct — the tests, checks, or inspections to run',
74
+ 'the single most direct next step toward completing the session goal',
43
75
  ] as const;
44
76
 
45
77
  // ── Per-session state (globalThis-shared) ───────────────────────────
46
78
 
47
79
  // eslint-disable-next-line no-restricted-syntax -- internal engine state, never exported
48
80
  interface EngineSession {
81
+ cancelled: boolean;
49
82
  errorCount: number;
50
83
  eventCount: number;
51
84
  events: string[];
85
+ // Highest signal + latest trigger seen during the OPEN grace window — evaluated at FIRE time, not
86
+ // schedule time, so an agent-idle arriving after a low-signal event still runs as a high trigger.
87
+ graceIsHigh: boolean;
52
88
  graceTimer: null | ReturnType<typeof setTimeout>;
89
+ graceTrigger: string;
53
90
  lastRunAt: number;
54
91
  projectName: string;
55
92
  running: boolean;
@@ -76,7 +113,7 @@ export function isAutopilotEnabled(): boolean {
76
113
  export function setAutopilotEnabled(value: boolean): void {
77
114
  enabled = value;
78
115
  try {
79
- mkdirSync(join(homedir(), '.shooter'), { recursive: true });
116
+ mkdirSync(shooterDataDir(), { recursive: true });
80
117
  writeFileSync(STATE_FILE, JSON.stringify({ enabled: value }), 'utf-8');
81
118
  } catch {
82
119
  // best-effort persistence
@@ -96,11 +133,30 @@ export function startAutopilotEngine(): void {
96
133
  if (unsubscribe) {
97
134
  return;
98
135
  }
99
- const control = { isEnabled: isAutopilotEnabled, setEnabled: setAutopilotEnabled };
136
+ const control = {
137
+ getGoal: getEngineGoal,
138
+ isEnabled: isAutopilotEnabled,
139
+ setEnabled: setAutopilotEnabled,
140
+ setGoal: setEngineGoal,
141
+ };
100
142
  (globalThis as Record<string, unknown>).__shooter_autopilot = control;
101
143
  unsubscribe = onShooterEvent(handleEvent);
144
+ // Pre-track sessions for terminals that already exist at startup (e.g. a server restart that
145
+ // reconnected persisted terminals). Without this a session is only created lazily on its NEXT
146
+ // event, so an agent that went idle before the engine attached would be invisible until it emits
147
+ // again. We deliberately do NOT force a pipeline run here — that would risk spurious LLM spend on
148
+ // a genuinely quiet terminal; we just ensure the session is tracked so the next event triggers.
149
+ try {
150
+ for (const term of ptyManager.list()) {
151
+ if (!sessions.has(term.id)) {
152
+ createSession(term.id);
153
+ }
154
+ }
155
+ } catch {
156
+ // ptyManager not ready yet — sessions will be created lazily on first event
157
+ }
102
158
  console.log(
103
- `[autopilot] engine started (enabled=${enabled}, litellm=${isLiteLLMConfigured() ? 'configured' : 'absent'})`
159
+ `[autopilot] engine started (enabled=${enabled}, litellm=${isLiteLLMConfigured() ? 'configured' : 'absent'}, lensConcurrency=${LENS_CONCURRENCY})`
104
160
  );
105
161
  }
106
162
 
@@ -118,7 +174,9 @@ function applyEvent(session: EngineSession, event: WireShooterEvent): void {
118
174
  parts.push(`cmd=${event.command.slice(0, 80)}`);
119
175
  }
120
176
  if ('message' in event && event.message) {
121
- parts.push(`msg=${event.message.slice(0, 120)}`);
177
+ // The agent's last message on idle (the OUTCOME — e.g. "the test failed because…") is the
178
+ // richest signal the lenses have; keep enough of it to actually ground the next-step votes.
179
+ parts.push(`msg=${event.message.slice(0, 400)}`);
122
180
  }
123
181
  session.events.push(parts.join(' '));
124
182
  if (session.events.length > MAX_EVENTS) {
@@ -146,10 +204,13 @@ function applyEvent(session: EngineSession, event: WireShooterEvent): void {
146
204
 
147
205
  function createSession(terminalId: string): EngineSession {
148
206
  const session: EngineSession = {
207
+ cancelled: false,
149
208
  errorCount: 0,
150
209
  eventCount: 0,
151
210
  events: [],
211
+ graceIsHigh: false,
152
212
  graceTimer: null,
213
+ graceTrigger: '',
153
214
  lastRunAt: 0,
154
215
  projectName: projectNameFor(terminalId),
155
216
  running: false,
@@ -165,8 +226,6 @@ function fallbackSummary(session: EngineSession): string {
165
226
  return `${session.status} — ${session.toolCallCount} tool calls, ${session.errorCount} errors`;
166
227
  }
167
228
 
168
- // ── Pipeline ─────────────────────────────────────────────────────────
169
-
170
229
  function handleEvent(event: WireShooterEvent): void {
171
230
  const terminalId = 'terminalId' in event ? event.terminalId : undefined;
172
231
  if (!terminalId) {
@@ -175,10 +234,14 @@ function handleEvent(event: WireShooterEvent): void {
175
234
 
176
235
  if (event.type === 'terminal-exited') {
177
236
  const s = sessions.get(terminalId);
178
- if (s?.graceTimer) {
179
- clearTimeout(s.graceTimer);
237
+ if (s) {
238
+ s.cancelled = true; // signal any in-flight pipeline to stop before persist/push
239
+ if (s.graceTimer) {
240
+ clearTimeout(s.graceTimer);
241
+ }
180
242
  }
181
243
  sessions.delete(terminalId);
244
+ clearEngineGoal(terminalId);
182
245
  return;
183
246
  }
184
247
 
@@ -198,15 +261,44 @@ function handleEvent(event: WireShooterEvent): void {
198
261
  if (Date.now() - session.lastRunAt < MIN_INTERVAL_MS) {
199
262
  return;
200
263
  }
201
- // Schedule once; do NOT reset on every event (so a burst doesn't delay the run).
264
+ // Track the strongest signal + latest trigger across the whole window (so a high-signal event
265
+ // arriving after the timer was armed by a low-signal one is not lost). Schedule the timer ONCE;
266
+ // do NOT reset it on every event (so a burst doesn't keep delaying the run).
202
267
  if (!session.graceTimer) {
268
+ session.graceIsHigh = isHigh;
269
+ session.graceTrigger = event.type;
203
270
  session.graceTimer = setTimeout(() => {
204
271
  session.graceTimer = null;
205
- void runPipeline(session, event.type, isHigh);
272
+ const trigger = session.graceTrigger;
273
+ const wasHigh = session.graceIsHigh;
274
+ void runPipeline(session, trigger, wasHigh);
206
275
  }, GRACE_MS);
276
+ } else {
277
+ session.graceIsHigh = session.graceIsHigh || isHigh;
278
+ session.graceTrigger = event.type;
207
279
  }
208
280
  }
209
281
 
282
+ // ── Pipeline ─────────────────────────────────────────────────────────
283
+
284
+ /** Run `fn` over `items` with at most `limit` in flight; preserves input order. */
285
+ async function mapLimit<T, R>(
286
+ items: readonly T[],
287
+ limit: number,
288
+ fn: (item: T) => Promise<R>
289
+ ): Promise<R[]> {
290
+ const results: R[] = new Array<R>(items.length);
291
+ let next = 0;
292
+ const worker = async (): Promise<void> => {
293
+ while (next < items.length) {
294
+ const idx = next++;
295
+ results[idx] = await fn(items[idx]);
296
+ }
297
+ };
298
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
299
+ return results;
300
+ }
301
+
210
302
  function persist(
211
303
  session: EngineSession,
212
304
  summary: string,
@@ -250,6 +342,12 @@ async function push(session: EngineSession, summary: string, steps: NextStep[]):
250
342
  if (!top) {
251
343
  return;
252
344
  }
345
+ // Presence-aware: when a viewer is foregrounded (watching the live dashboard) skip the
346
+ // push — they see it in-app. Push only when away. If no presence-aware client ever
347
+ // reported, isViewerPresent() is false → push proceeds (prior always-push behavior).
348
+ if (isViewerPresent()) {
349
+ return;
350
+ }
253
351
  const port = process.env.PORT || '54007';
254
352
  const apiKey = process.env.API_KEY;
255
353
  if (!apiKey) {
@@ -300,38 +398,45 @@ async function runPipeline(
300
398
  session.running = true;
301
399
  session.lastRunAt = Date.now();
302
400
  try {
303
- const context =
304
- `Project: ${session.projectName}\nStatus: ${session.status}\nErrors: ${session.errorCount}\n` +
305
- `Tool calls: ${session.toolCallCount}\nRecent events: ${session.events.slice(-12).join('; ')}\n` +
306
- `Trigger: ${trigger}`;
401
+ const context = buildEngineContext({
402
+ errorCount: session.errorCount,
403
+ events: session.events,
404
+ goal: getEngineGoal(session.terminalId),
405
+ projectName: session.projectName,
406
+ status: session.status,
407
+ toolCallCount: session.toolCallCount,
408
+ trigger,
409
+ });
307
410
 
308
411
  const summaryResult = await litellmJson<{ summary: string }>({
309
412
  maxTokens: SUMMARY_MAX_TOKENS,
310
- systemInstruction:
311
- 'You are a coding-session monitor. Respond ONLY with valid JSON: {"summary":"<one sentence, max 120 chars>"}. No markdown, no prose outside the JSON.',
413
+ model: ENGINE_MODEL,
414
+ systemInstruction: `${JSON_API_RULES} The object has one key "summary": a single sentence (max 120 characters) describing the current status of this coding session.`,
312
415
  userPrompt: `${context}\n\nSummarise what is happening in this coding session in ONE sentence (max 120 chars).`,
313
416
  });
314
417
  const summary = summaryResult?.summary?.trim() || fallbackSummary(session);
315
418
 
316
- const lensResults = await Promise.allSettled(
317
- LENSES.map((lens) =>
318
- litellmJson<{ steps: AgentProposal[] }>({
319
- maxTokens: STEPS_MAX_TOKENS,
320
- systemInstruction: `${lens} Respond ONLY with valid JSON: {"steps":[{"text":"<short action>","confidence":0.9}]}. Up to 3 steps, confidence 0-1. No markdown.`,
321
- userPrompt: `${context}\n\nGiven the session above, what should happen next from your perspective?`,
322
- })
323
- )
324
- );
325
- // Keep all 5 lists (including empty on failure) so quorum stays 3-of-5.
326
- const agentLists: AgentProposal[][] = lensResults.map((r) =>
327
- r.status === 'fulfilled' ? (r.value?.steps ?? []) : []
328
- );
329
- const consensus = mergeNextStepConsensus(agentLists);
419
+ // Run the five lenses at most LENS_CONCURRENCY in flight (default 3) so the engine never
420
+ // saturates the LiteLLM key's parallel cap by itself. Each failed lens yields an empty list;
421
+ // a step needs CONSENSUS_QUORUM (2) of the 5 to be non-tentative.
422
+ const agentLists: AgentProposal[][] = await mapLimit(LENSES, LENS_CONCURRENCY, async (lens) => {
423
+ const r = await litellmJson<{ steps: AgentProposal[] }>({
424
+ maxTokens: STEPS_MAX_TOKENS,
425
+ model: ENGINE_MODEL,
426
+ systemInstruction: `${JSON_API_RULES} The object has one key "steps": an array of 1 to 3 objects, each {"text": the concrete next action for THIS exact session written in full as a string, "confidence": a number between 0 and 1}. Choose the actions from THIS perspective: ${lens}.`,
427
+ userPrompt: `${context}\n\nGiven the session above, what should happen next?`,
428
+ });
429
+ return r?.steps ?? [];
430
+ });
431
+ const consensus = mergeNextStepConsensus(agentLists, { quorum: CONSENSUS_QUORUM });
330
432
 
331
- if (!enabled) {
332
- return;
433
+ if (!enabled || session.cancelled) {
434
+ return; // engine disabled or terminal exited mid-pipeline — don't persist/push a dead session
333
435
  }
334
436
  persist(session, summary, consensus.steps, trigger);
437
+ // A run consumed the accumulated errors — reset so the error-threshold trigger (errorCount >= 3)
438
+ // does not stick and re-fire on every subsequent event for the rest of the session's life.
439
+ session.errorCount = 0;
335
440
  if (isHigh && consensus.steps.length > 0 && !consensus.steps[0].tentative) {
336
441
  await push(session, summary, consensus.steps);
337
442
  }
@@ -2,7 +2,9 @@
2
2
  // directly with the server-side key (process.env) — no proxy, no browser
3
3
  // exposure. Used by the always-on autopilot engine.
4
4
 
5
- const REQUEST_TIMEOUT_MS = 20_000;
5
+ const REQUEST_TIMEOUT_MS = 30_000;
6
+ const MAX_ATTEMPTS = 2;
7
+ const BACKOFF_MS = [800]; // backoff before the single retry (rate-limit / transient failures)
6
8
 
7
9
  /** Pull the model's JSON out of an OpenAI-shaped chat-completion response. */
8
10
  export function extractJsonContent(data: unknown): unknown {
@@ -25,6 +27,8 @@ export function isLiteLLMConfigured(): boolean {
25
27
  */
26
28
  export async function litellmJson<T>(opts: {
27
29
  maxTokens?: number;
30
+ /** Override the model for this call (e.g. the autopilot engine pins a non-reasoning model). */
31
+ model?: string;
28
32
  systemInstruction: string;
29
33
  userPrompt: string;
30
34
  }): Promise<null | T> {
@@ -32,34 +36,20 @@ export async function litellmJson<T>(opts: {
32
36
  if (!cfg) {
33
37
  return null;
34
38
  }
35
- const controller = new AbortController();
36
- const timer = setTimeout(() => {
37
- controller.abort();
38
- }, REQUEST_TIMEOUT_MS);
39
- try {
40
- const res = await fetch(`${cfg.base}/chat/completions`, {
41
- body: JSON.stringify({
42
- max_tokens: opts.maxTokens ?? 400,
43
- messages: [
44
- { content: opts.systemInstruction, role: 'system' },
45
- { content: opts.userPrompt, role: 'user' },
46
- ],
47
- model: cfg.model,
48
- }),
49
- headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
50
- method: 'POST',
51
- signal: controller.signal,
52
- });
53
- if (!res.ok) {
54
- return null;
39
+ // Attempt with JSON mode (forces the model to emit ONLY valid JSON — verbose/reasoning models
40
+ // otherwise wrap prose around it), retrying with backoff for rate-limit / transient burst
41
+ // failures. If every JSON-mode attempt fails, fall back ONCE without response_format in case the
42
+ // gateway rejects it — so a non-supporting backend degrades to prior behavior instead of breaking.
43
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
44
+ const result = await attemptCompletion<T>(cfg, opts, true);
45
+ if (result !== null) {
46
+ return result;
47
+ }
48
+ if (attempt < MAX_ATTEMPTS - 1) {
49
+ await delay(BACKOFF_MS[attempt]);
55
50
  }
56
- const data: unknown = await res.json();
57
- return extractJsonContent(data) as null | T;
58
- } catch {
59
- return null;
60
- } finally {
61
- clearTimeout(timer);
62
51
  }
52
+ return attemptCompletion<T>(cfg, opts, false);
63
53
  }
64
54
 
65
55
  /**
@@ -79,28 +69,94 @@ export function parseJsonResponse(raw: string): unknown {
79
69
  try {
80
70
  return JSON.parse(cleaned);
81
71
  } catch {
82
- const start = cleaned.indexOf('{');
83
- if (start !== -1) {
72
+ // Scan for the FIRST balanced {...} region that actually parses. A reasoning model often emits
73
+ // an invalid brace region (e.g. "{not: valid}") in its preamble BEFORE the real JSON, so on a
74
+ // failed candidate we must advance to the next "{" and keep scanning — not give up (the old
75
+ // `return null` here discarded every later region and collapsed consensus to empty).
76
+ let start = cleaned.indexOf('{');
77
+ while (start !== -1) {
84
78
  let depth = 0;
79
+ let end = -1;
85
80
  for (let i = start; i < cleaned.length; i++) {
86
81
  if (cleaned[i] === '{') {
87
82
  depth++;
88
83
  } else if (cleaned[i] === '}') {
89
84
  depth--;
90
85
  if (depth === 0) {
91
- try {
92
- return JSON.parse(cleaned.slice(start, i + 1));
93
- } catch {
94
- return null;
95
- }
86
+ end = i;
87
+ break;
96
88
  }
97
89
  }
98
90
  }
91
+ if (end === -1) {
92
+ return null; // unbalanced through end of string — nothing more to try
93
+ }
94
+ try {
95
+ return JSON.parse(cleaned.slice(start, end + 1));
96
+ } catch {
97
+ start = cleaned.indexOf('{', start + 1);
98
+ }
99
99
  }
100
100
  return null;
101
101
  }
102
102
  }
103
103
 
104
+ async function attemptCompletion<T>(
105
+ cfg: { base: string; key: string; model: string },
106
+ opts: { maxTokens?: number; model?: string; systemInstruction: string; userPrompt: string },
107
+ jsonMode: boolean
108
+ ): Promise<null | T> {
109
+ const controller = new AbortController();
110
+ const timer = setTimeout(() => {
111
+ controller.abort();
112
+ }, REQUEST_TIMEOUT_MS);
113
+ try {
114
+ const body: Record<string, unknown> = {
115
+ max_tokens: opts.maxTokens ?? 400,
116
+ messages: [
117
+ { content: opts.systemInstruction, role: 'system' },
118
+ { content: opts.userPrompt, role: 'user' },
119
+ ],
120
+ model: opts.model?.trim() || cfg.model,
121
+ temperature: 0,
122
+ };
123
+ if (jsonMode) {
124
+ body.response_format = { type: 'json_object' };
125
+ }
126
+ const res = await fetch(`${cfg.base}/chat/completions`, {
127
+ body: JSON.stringify(body),
128
+ headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
129
+ method: 'POST',
130
+ signal: controller.signal,
131
+ });
132
+ if (!res.ok) {
133
+ // Surface the failure — silently returning null here made the whole autopilot pipeline
134
+ // produce empty consensus invisibly (no log, no signal). 429 = the LiteLLM key's
135
+ // max_parallel_requests is exhausted (often by other processes sharing the key).
136
+ const detail = await res.text().catch(() => '');
137
+ console.warn(
138
+ `[litellm] HTTP ${res.status} (model=${body.model as string}, jsonMode=${jsonMode}): ${detail.slice(0, 200)}`
139
+ );
140
+ return null;
141
+ }
142
+ const data: unknown = await res.json();
143
+ return extractJsonContent(data) as null | T;
144
+ } catch (err) {
145
+ const reason = err instanceof Error ? err.message : String(err);
146
+ // AbortError = our REQUEST_TIMEOUT_MS fired (the gateway hung). Anything else = network/parse.
147
+ console.warn(`[litellm] request failed (model=${opts.model?.trim() || cfg.model}): ${reason}`);
148
+ return null;
149
+ } finally {
150
+ clearTimeout(timer);
151
+ }
152
+ }
153
+
154
+ function delay(ms: number): Promise<void> {
155
+ return new Promise((resolve) => {
156
+ setTimeout(resolve, ms);
157
+ });
158
+ }
159
+
104
160
  function litellmConfig(): null | { base: string; key: string; model: string } {
105
161
  const base = process.env.LITELLM_BASE_URL?.trim();
106
162
  const key = process.env.LITELLM_API_KEY?.trim();
@@ -60,7 +60,14 @@ export function mergeNextStepConsensus(
60
60
  // Step 3: find an existing cluster to join.
61
61
  const targetCluster = findOrCreateCluster(clusters, norm);
62
62
  targetCluster.agentIndices.add(agentIndex);
63
- targetCluster.members.push({ confidence: proposal.confidence, text: proposal.text });
63
+ // Sanitise confidence at ingestion: the type says `number` but the value comes from raw LLM
64
+ // JSON, so a missing/NaN/Infinity/out-of-range confidence must NOT leak into meanConf (it
65
+ // would poison the mean and could spuriously clear the downstream 0.7 inject floor). Invalid
66
+ // → 0 (correctly fails the floor); valid → clamped to [0,1].
67
+ targetCluster.members.push({
68
+ confidence: safeConfidence(proposal.confidence),
69
+ text: proposal.text,
70
+ });
64
71
  }
65
72
  }
66
73
 
@@ -175,11 +182,29 @@ function normalize(text: string): string {
175
182
  .replace(/[.,;:!?]+$/, '');
176
183
  }
177
184
 
185
+ /** Coerce a raw (untrusted) confidence into a finite number in [0,1]; invalid → 0. */
186
+ function safeConfidence(value: unknown): number {
187
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
188
+ return 0;
189
+ }
190
+ return Math.max(0, Math.min(1, value));
191
+ }
192
+
178
193
  // ── Private helper ──────────────────────────────────────────────────────────
179
194
 
180
195
  /**
181
196
  * Tokenize a normalized string into a set of tokens.
182
197
  */
183
198
  function tokenSet(normalized: string): Set<string> {
184
- return new Set(normalized.split(/\s+/).filter((t) => t.length > 0));
199
+ // Strip wrapping punctuation from each token so code-bearing proposals cluster: the lenses phrase
200
+ // the same action differently and wrap code in backticks/quotes (`return a - b`, "calc.js"), which
201
+ // otherwise fragments the tokens ("`return" ≠ "return") and makes the Jaccard overlap undercount
202
+ // real agreement. Pure-punctuation tokens (operators like - / +) drop out, which is fine — we are
203
+ // grouping intent, not preserving exact syntax.
204
+ return new Set(
205
+ normalized
206
+ .split(/\s+/)
207
+ .map((t) => t.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ''))
208
+ .filter((t) => t.length > 0)
209
+ );
185
210
  }
@@ -14,9 +14,11 @@ import Database from 'better-sqlite3';
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
 
17
+ import { shooterDataDir } from '../utils/shooter-home.js';
18
+
17
19
  // ── Constants ────────────────────────────────────────────────────────
18
20
 
19
- const DB_DIR = path.join(process.env.HOME || '', '.shooter');
21
+ const DB_DIR = shooterDataDir();
20
22
  const DB_PATH = path.join(DB_DIR, 'shooter.db');
21
23
 
22
24
  // ── Column list ──────────────────────────────────────────────────────
@@ -0,0 +1,26 @@
1
+ // Inject a default `--permission-mode` into a claude launch, configurable via the
2
+ // SHOOTER_AGENT_PERMISSION_MODE env var, so managed agent terminals can actually act instead of
3
+ // being auto-denied by a restrictive global Claude config. Claude-only (the flag is
4
+ // claude-specific) and never overrides an explicit --permission-mode the caller passed.
5
+ // Pure — unit-tested in tests/agent-launch.test.cjs.
6
+
7
+ const CLAUDE_COMMANDS = new Set(['claude']);
8
+
9
+ export function withAgentPermissionMode(
10
+ command: string,
11
+ args: string[],
12
+ mode: string | undefined
13
+ ): string[] {
14
+ const trimmed = (mode ?? '').trim();
15
+ if (trimmed.length === 0) {
16
+ return args; // feature off when unset
17
+ }
18
+ const base = command.split('/').pop() ?? command;
19
+ if (!CLAUDE_COMMANDS.has(base)) {
20
+ return args; // --permission-mode is claude-specific
21
+ }
22
+ if (args.includes('--permission-mode')) {
23
+ return args; // an explicit flag always wins
24
+ }
25
+ return [...args, '--permission-mode', trimmed];
26
+ }
@@ -114,6 +114,12 @@ try {
114
114
  /* best effort */
115
115
  }
116
116
  ptyEnv.SHOOTER_CLIPBOARD_DIR = clipboardDir;
117
+ // Tag this PTY with its Shooter terminal id so in-agent lifecycle hooks
118
+ // (notifier.cjs) can attribute their events to THIS managed terminal. The
119
+ // autopilot engine + phone driver key on terminalId; a plain shell that a
120
+ // user starts outside Shooter never sees this var, so its behaviour is
121
+ // unchanged.
122
+ ptyEnv.SHOOTER_TERMINAL_ID = id;
117
123
 
118
124
  // Prepend clipboard shim scripts to PATH so tools find our xclip/wl-paste
119
125
  const shimsDir = path.resolve(__dirname, '..', 'scripts', 'clipboard-shims');
@@ -18,6 +18,7 @@ import {
18
18
  readOnlySourceForCommand,
19
19
  } from '../sessions/provider-paths';
20
20
  import { broadcastEvent } from '../ws/server.js';
21
+ import { withAgentPermissionMode } from './agent-launch.js';
21
22
  import { HolderClient } from './holder-client';
22
23
  import { openCodeWatcher } from './opencode-watcher';
23
24
  import { terminalStore } from './terminal-store';
@@ -134,8 +135,17 @@ class PtyManager {
134
135
  const socketPath = `/tmp/shooter-term-${id}.sock`;
135
136
  const holderScript = resolveHolderPath();
136
137
 
138
+ // Inject the configured default --permission-mode for claude (SHOOTER_AGENT_PERMISSION_MODE),
139
+ // so a managed agent can act instead of being auto-denied by a restrictive global config.
140
+ // No-op unless set; never overrides an explicit flag. Persisted so reconnect keeps it.
141
+ const launchArgs = withAgentPermissionMode(
142
+ command,
143
+ args,
144
+ process.env.SHOOTER_AGENT_PERMISSION_MODE
145
+ );
146
+
137
147
  // Fork the holder process as detached so it survives server restarts
138
- const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...args];
148
+ const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...launchArgs];
139
149
  const holder: ChildProcess = fork(holderScript, holderArgs, {
140
150
  detached: true,
141
151
  stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
@@ -179,7 +189,7 @@ class PtyManager {
179
189
 
180
190
  const now = new Date();
181
191
  const terminal: ManagedTerminal = {
182
- args,
192
+ args: launchArgs,
183
193
  clients: new Set(),
184
194
  cols,
185
195
  command,
@@ -210,7 +220,7 @@ class PtyManager {
210
220
 
211
221
  // Persist to SQLite
212
222
  terminalStore.insert({
213
- args: JSON.stringify(args),
223
+ args: JSON.stringify(launchArgs),
214
224
  cols,
215
225
  command,
216
226
  createdAt: now.toISOString(),
@@ -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, '');