hammoc 1.8.4 → 1.9.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 (304) hide show
  1. package/README.md +9 -6
  2. package/package.json +1 -1
  3. package/packages/client/dist/assets/MermaidViewer-B-LwclIl.js +7 -0
  4. package/packages/client/dist/assets/PdfViewer-C3Nymwn3.js +12 -0
  5. package/packages/client/dist/assets/PdfViewer-QCQEGvBO.css +1 -0
  6. package/packages/client/dist/assets/abnfDiagram-VRR7QNED-GfhY_hnV.js +1 -0
  7. package/packages/client/dist/assets/{agentExampleHighlight-Dws80em9.js → agentExampleHighlight-CyhVLy0_.js} +1 -1
  8. package/packages/client/dist/assets/arc-8nyjXGC3.js +1 -0
  9. package/packages/client/dist/assets/architectureDiagram-ZJ3FMSHR-B4onXLm4.js +36 -0
  10. package/packages/client/dist/assets/blockDiagram-677ZJIJ3-D0D6ncaN.js +132 -0
  11. package/packages/client/dist/assets/c4Diagram-LMCZKHZV-CE7Tip-_.js +10 -0
  12. package/packages/client/dist/assets/channel-CPjlbdOo.js +1 -0
  13. package/packages/client/dist/assets/chunk-2Q5K7J3B-4rW1da1Z.js +1 -0
  14. package/packages/client/dist/assets/chunk-32BRIVSS-JuzxS4QA.js +1 -0
  15. package/packages/client/dist/assets/chunk-5VM5RSS4-WRNf-xsi.js +15 -0
  16. package/packages/client/dist/assets/chunk-EX3LRPZG-BDWPlBKO.js +231 -0
  17. package/packages/client/dist/assets/chunk-JWPE2WC7-B8nCyQAc.js +1 -0
  18. package/packages/client/dist/assets/chunk-MOJQB5TN-7c-yriHe.js +88 -0
  19. package/packages/client/dist/assets/chunk-RYQCIY6F-LTDywRs7.js +1 -0
  20. package/packages/client/dist/assets/chunk-V7JOEXUC-DjVgQpjl.js +206 -0
  21. package/packages/client/dist/assets/chunk-VR4S4FIN-7jDdkUpx.js +1 -0
  22. package/packages/client/dist/assets/chunk-XXDRQBXY-ENKV2O8a.js +1 -0
  23. package/packages/client/dist/assets/classDiagram-OUVF2IWQ-C0PbC7Pi.js +1 -0
  24. package/packages/client/dist/assets/classDiagram-v2-EOCWNBFH-C0PbC7Pi.js +1 -0
  25. package/packages/client/dist/assets/{commandTokenHighlight-aR071CNy.js → commandTokenHighlight-DGP0RKx0.js} +1 -1
  26. package/packages/client/dist/assets/cose-bilkent-JH36ORCC-CQTNdQ-g.js +1 -0
  27. package/packages/client/dist/assets/cynefin-VYW2F7L2-BYlAJRcO.js +178 -0
  28. package/packages/client/dist/assets/cynefinDiagram-TSTJHNR4-7zyUTflT.js +62 -0
  29. package/packages/client/dist/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  30. package/packages/client/dist/assets/dagre-VKFMJZFB-DyZir42W.js +4 -0
  31. package/packages/client/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  32. package/packages/client/dist/assets/diagram-FQU43EPY-CmNAU8Nw.js +3 -0
  33. package/packages/client/dist/assets/diagram-G47NLZAW-B0EIk03o.js +24 -0
  34. package/packages/client/dist/assets/diagram-NH7WQ7WH-Dulv0f1N.js +24 -0
  35. package/packages/client/dist/assets/diagram-OA4YK3LP-DEEcMLgi.js +30 -0
  36. package/packages/client/dist/assets/diagram-WEI45ONY-ZYebAZ93.js +41 -0
  37. package/packages/client/dist/assets/ebnfDiagram-CCIWWBDH-DCHpt3qn.js +1 -0
  38. package/packages/client/dist/assets/erDiagram-Q63AITRT-B6T87-48.js +85 -0
  39. package/packages/client/dist/assets/flowDiagram-23GEKE2U-DAcW6mKJ.js +156 -0
  40. package/packages/client/dist/assets/ganttDiagram-NO4QXBWP-Ct_eFFcM.js +292 -0
  41. package/packages/client/dist/assets/gitGraphDiagram-IHSO6WYX-BGi8Fdm2.js +106 -0
  42. package/packages/client/dist/assets/graph-C9eacEi8.js +1 -0
  43. package/packages/client/dist/assets/index-CtMiUbMJ.js +1671 -0
  44. package/packages/client/dist/assets/index-hTu6ZG2N.js +2 -0
  45. package/packages/client/dist/assets/index-oBa2uNlv.css +32 -0
  46. package/packages/client/dist/assets/infoDiagram-FWYZ7A6U-CqvwbXJn.js +2 -0
  47. package/packages/client/dist/assets/init-Gi6I4Gst.js +1 -0
  48. package/packages/client/dist/assets/ishikawaDiagram-FXEZZL3T-CHhr5ioQ.js +70 -0
  49. package/packages/client/dist/assets/journeyDiagram-5HDEW3XC-d20NzZAd.js +139 -0
  50. package/packages/client/dist/assets/kanban-definition-HUTT4EX6-CHx3wOer.js +89 -0
  51. package/packages/client/dist/assets/katex-C5jXJg4s.js +257 -0
  52. package/packages/client/dist/assets/layout-DEXfKzaS.js +1 -0
  53. package/packages/client/dist/assets/linear-Cnp1E9M4.js +1 -0
  54. package/packages/client/dist/assets/map-Czzmt4hB.js +1 -0
  55. package/packages/client/dist/assets/mermaid.core-jAhLhPYr.js +314 -0
  56. package/packages/client/dist/assets/mindmap-definition-LN4V7U3C-zi30RPoJ.js +96 -0
  57. package/packages/client/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  58. package/packages/client/dist/assets/pdf.worker.min-qwK7q_zL.mjs +28 -0
  59. package/packages/client/dist/assets/pegDiagram-2B236MQR-4gKLzTqu.js +1 -0
  60. package/packages/client/dist/assets/pieDiagram-ENE6RG2P-DRDhpjxP.js +39 -0
  61. package/packages/client/dist/assets/quadrantDiagram-ABIIQ3AL-FmTxRIlW.js +7 -0
  62. package/packages/client/dist/assets/railroadDiagram-RFXS5EU6-Bc-Q7iPs.js +1 -0
  63. package/packages/client/dist/assets/requirementDiagram-TGXJPOKE-D209tlqv.js +84 -0
  64. package/packages/client/dist/assets/sankeyDiagram-HTMAVEWB--I_lSbCV.js +40 -0
  65. package/packages/client/dist/assets/sequenceDiagram-DBY2YBRQ-Da5YJcDJ.js +162 -0
  66. package/packages/client/dist/assets/sizeCapture-X5ZJPWSS-DYuc-rzB.js +1 -0
  67. package/packages/client/dist/assets/{snippetTokenHighlight-ot6u3hW4.js → snippetTokenHighlight-qXHxvlCE.js} +1 -1
  68. package/packages/client/dist/assets/stateDiagram-2N3HPSRC-DoOeZoRP.js +1 -0
  69. package/packages/client/dist/assets/stateDiagram-v2-6OUMAXLB-DLkUyWs6.js +1 -0
  70. package/packages/client/dist/assets/swimlanes-5IMT3BWC-BcR7uCTf.js +2 -0
  71. package/packages/client/dist/assets/swimlanesDiagram-G3AALYLV-C0FDo-hf.js +8 -0
  72. package/packages/client/dist/assets/timeline-definition-FHXFAJF6-CLzKR6_i.js +120 -0
  73. package/packages/client/dist/assets/vennDiagram-L72KCM5P-TajEacPR.js +34 -0
  74. package/packages/client/dist/assets/wardleyDiagram-EHGQE667-DKvVEb3V.js +78 -0
  75. package/packages/client/dist/assets/xychartDiagram-FW5EYKEG-CaJG8r0j.js +7 -0
  76. package/packages/client/dist/index.html +2 -2
  77. package/packages/client/dist/sw.js +2 -2
  78. package/packages/server/dist/controllers/accountController.d.ts.map +1 -1
  79. package/packages/server/dist/controllers/accountController.js +31 -11
  80. package/packages/server/dist/controllers/accountController.js.map +1 -1
  81. package/packages/server/dist/controllers/fileSystemController.d.ts +6 -0
  82. package/packages/server/dist/controllers/fileSystemController.d.ts.map +1 -1
  83. package/packages/server/dist/controllers/fileSystemController.js +34 -0
  84. package/packages/server/dist/controllers/fileSystemController.js.map +1 -1
  85. package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
  86. package/packages/server/dist/controllers/projectController.js +4 -2
  87. package/packages/server/dist/controllers/projectController.js.map +1 -1
  88. package/packages/server/dist/controllers/sessionController.d.ts +5 -0
  89. package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
  90. package/packages/server/dist/controllers/sessionController.js +95 -3
  91. package/packages/server/dist/controllers/sessionController.js.map +1 -1
  92. package/packages/server/dist/handlers/hookBridge.d.ts +131 -0
  93. package/packages/server/dist/handlers/hookBridge.d.ts.map +1 -0
  94. package/packages/server/dist/handlers/hookBridge.js +178 -0
  95. package/packages/server/dist/handlers/hookBridge.js.map +1 -0
  96. package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
  97. package/packages/server/dist/handlers/streamCallbacks.js +30 -4
  98. package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
  99. package/packages/server/dist/handlers/websocket.d.ts +44 -2
  100. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  101. package/packages/server/dist/handlers/websocket.js +666 -64
  102. package/packages/server/dist/handlers/websocket.js.map +1 -1
  103. package/packages/server/dist/index.js +4 -1
  104. package/packages/server/dist/index.js.map +1 -1
  105. package/packages/server/dist/locales/en/server.json +9 -3
  106. package/packages/server/dist/locales/es/server.json +9 -3
  107. package/packages/server/dist/locales/ja/server.json +9 -3
  108. package/packages/server/dist/locales/ko/server.json +9 -3
  109. package/packages/server/dist/locales/pt/server.json +9 -3
  110. package/packages/server/dist/locales/zh-CN/server.json +9 -3
  111. package/packages/server/dist/routes/account.d.ts.map +1 -1
  112. package/packages/server/dist/routes/account.js +4 -2
  113. package/packages/server/dist/routes/account.js.map +1 -1
  114. package/packages/server/dist/routes/fileSystem.d.ts.map +1 -1
  115. package/packages/server/dist/routes/fileSystem.js +2 -0
  116. package/packages/server/dist/routes/fileSystem.js.map +1 -1
  117. package/packages/server/dist/routes/sessions.d.ts.map +1 -1
  118. package/packages/server/dist/routes/sessions.js +2 -0
  119. package/packages/server/dist/routes/sessions.js.map +1 -1
  120. package/packages/server/dist/services/accountInfoService.d.ts +36 -1
  121. package/packages/server/dist/services/accountInfoService.d.ts.map +1 -1
  122. package/packages/server/dist/services/accountInfoService.js +90 -4
  123. package/packages/server/dist/services/accountInfoService.js.map +1 -1
  124. package/packages/server/dist/services/accountStorageService.d.ts +55 -9
  125. package/packages/server/dist/services/accountStorageService.d.ts.map +1 -1
  126. package/packages/server/dist/services/accountStorageService.js +244 -12
  127. package/packages/server/dist/services/accountStorageService.js.map +1 -1
  128. package/packages/server/dist/services/chatEngine.d.ts +59 -3
  129. package/packages/server/dist/services/chatEngine.d.ts.map +1 -1
  130. package/packages/server/dist/services/chatService.d.ts +112 -3
  131. package/packages/server/dist/services/chatService.d.ts.map +1 -1
  132. package/packages/server/dist/services/chatService.js +353 -98
  133. package/packages/server/dist/services/chatService.js.map +1 -1
  134. package/packages/server/dist/services/claudeLoginService.d.ts +14 -3
  135. package/packages/server/dist/services/claudeLoginService.d.ts.map +1 -1
  136. package/packages/server/dist/services/claudeLoginService.js +18 -4
  137. package/packages/server/dist/services/claudeLoginService.js.map +1 -1
  138. package/packages/server/dist/services/cliChatEngine.d.ts.map +1 -1
  139. package/packages/server/dist/services/cliChatEngine.js +124 -22
  140. package/packages/server/dist/services/cliChatEngine.js.map +1 -1
  141. package/packages/server/dist/services/cliModalDetect.d.ts +14 -0
  142. package/packages/server/dist/services/cliModalDetect.d.ts.map +1 -1
  143. package/packages/server/dist/services/cliModalDetect.js +12 -3
  144. package/packages/server/dist/services/cliModalDetect.js.map +1 -1
  145. package/packages/server/dist/services/cliSessionPool.js +1 -1
  146. package/packages/server/dist/services/cliSessionPool.js.map +1 -1
  147. package/packages/server/dist/services/commandService.d.ts.map +1 -1
  148. package/packages/server/dist/services/commandService.js +19 -9
  149. package/packages/server/dist/services/commandService.js.map +1 -1
  150. package/packages/server/dist/services/engineResidencyRegistry.d.ts +112 -0
  151. package/packages/server/dist/services/engineResidencyRegistry.d.ts.map +1 -0
  152. package/packages/server/dist/services/engineResidencyRegistry.js +195 -0
  153. package/packages/server/dist/services/engineResidencyRegistry.js.map +1 -0
  154. package/packages/server/dist/services/harnessAgentService.d.ts.map +1 -1
  155. package/packages/server/dist/services/harnessAgentService.js +12 -2
  156. package/packages/server/dist/services/harnessAgentService.js.map +1 -1
  157. package/packages/server/dist/services/harnessLintService.d.ts.map +1 -1
  158. package/packages/server/dist/services/harnessLintService.js +25 -114
  159. package/packages/server/dist/services/harnessLintService.js.map +1 -1
  160. package/packages/server/dist/services/historyParser.d.ts.map +1 -1
  161. package/packages/server/dist/services/historyParser.js +23 -6
  162. package/packages/server/dist/services/historyParser.js.map +1 -1
  163. package/packages/server/dist/services/imageStorageService.d.ts.map +1 -1
  164. package/packages/server/dist/services/imageStorageService.js +7 -0
  165. package/packages/server/dist/services/imageStorageService.js.map +1 -1
  166. package/packages/server/dist/services/projectService.d.ts +4 -1
  167. package/packages/server/dist/services/projectService.d.ts.map +1 -1
  168. package/packages/server/dist/services/projectService.js +14 -1
  169. package/packages/server/dist/services/projectService.js.map +1 -1
  170. package/packages/server/dist/services/promptQueue.d.ts +34 -0
  171. package/packages/server/dist/services/promptQueue.d.ts.map +1 -0
  172. package/packages/server/dist/services/promptQueue.js +80 -0
  173. package/packages/server/dist/services/promptQueue.js.map +1 -0
  174. package/packages/server/dist/services/ptyService.d.ts.map +1 -1
  175. package/packages/server/dist/services/ptyService.js +4 -1
  176. package/packages/server/dist/services/ptyService.js.map +1 -1
  177. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  178. package/packages/server/dist/services/queueService.js +62 -4
  179. package/packages/server/dist/services/queueService.js.map +1 -1
  180. package/packages/server/dist/services/rateLimitProbeService.d.ts +73 -3
  181. package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
  182. package/packages/server/dist/services/rateLimitProbeService.js +397 -30
  183. package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
  184. package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -1
  185. package/packages/server/dist/services/sessionBufferManager.js +22 -4
  186. package/packages/server/dist/services/sessionBufferManager.js.map +1 -1
  187. package/packages/server/dist/services/sessionExportService.d.ts +34 -0
  188. package/packages/server/dist/services/sessionExportService.d.ts.map +1 -0
  189. package/packages/server/dist/services/sessionExportService.js +269 -0
  190. package/packages/server/dist/services/sessionExportService.js.map +1 -0
  191. package/packages/server/dist/services/sessionService.d.ts +6 -0
  192. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  193. package/packages/server/dist/services/sessionService.js +30 -9
  194. package/packages/server/dist/services/sessionService.js.map +1 -1
  195. package/packages/server/dist/services/streamHandler.d.ts +177 -1
  196. package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
  197. package/packages/server/dist/services/streamHandler.js +425 -39
  198. package/packages/server/dist/services/streamHandler.js.map +1 -1
  199. package/packages/server/dist/services/workspaceContext.d.ts.map +1 -1
  200. package/packages/server/dist/services/workspaceContext.js +6 -0
  201. package/packages/server/dist/services/workspaceContext.js.map +1 -1
  202. package/packages/server/dist/snippets/fix-lint-command-not-on-path +13 -0
  203. package/packages/server/dist/snippets/fix-lint-duplicate-name +12 -0
  204. package/packages/server/dist/snippets/fix-lint-env-var-undefined +13 -0
  205. package/packages/server/dist/snippets/fix-lint-regex-invalid +14 -0
  206. package/packages/server/dist/snippets/fix-lint-tools-non-standard +14 -0
  207. package/packages/server/dist/snippets/fix-lint-url-invalid +13 -0
  208. package/packages/server/dist/snippets/fix-lint-yaml-parse-error +14 -0
  209. package/packages/server/dist/snippets/fix-malformed-skill +17 -0
  210. package/packages/server/dist/utils/backgroundHookSettings.d.ts +31 -0
  211. package/packages/server/dist/utils/backgroundHookSettings.d.ts.map +1 -0
  212. package/packages/server/dist/utils/backgroundHookSettings.js +41 -0
  213. package/packages/server/dist/utils/backgroundHookSettings.js.map +1 -0
  214. package/packages/server/dist/utils/backgroundTaskPoller.d.ts +69 -0
  215. package/packages/server/dist/utils/backgroundTaskPoller.d.ts.map +1 -0
  216. package/packages/server/dist/utils/backgroundTaskPoller.js +356 -0
  217. package/packages/server/dist/utils/backgroundTaskPoller.js.map +1 -0
  218. package/packages/server/dist/utils/backgroundTaskTracker.d.ts +51 -3
  219. package/packages/server/dist/utils/backgroundTaskTracker.d.ts.map +1 -1
  220. package/packages/server/dist/utils/backgroundTaskTracker.js +117 -9
  221. package/packages/server/dist/utils/backgroundTaskTracker.js.map +1 -1
  222. package/packages/server/dist/utils/backgroundToolGuard.d.ts +26 -0
  223. package/packages/server/dist/utils/backgroundToolGuard.d.ts.map +1 -0
  224. package/packages/server/dist/utils/backgroundToolGuard.js +40 -0
  225. package/packages/server/dist/utils/backgroundToolGuard.js.map +1 -0
  226. package/packages/server/dist/utils/boundedRing.d.ts +44 -0
  227. package/packages/server/dist/utils/boundedRing.d.ts.map +1 -0
  228. package/packages/server/dist/utils/boundedRing.js +77 -0
  229. package/packages/server/dist/utils/boundedRing.js.map +1 -0
  230. package/packages/server/dist/utils/effortUtils.d.ts +7 -6
  231. package/packages/server/dist/utils/effortUtils.d.ts.map +1 -1
  232. package/packages/server/dist/utils/effortUtils.js +10 -9
  233. package/packages/server/dist/utils/effortUtils.js.map +1 -1
  234. package/packages/server/dist/utils/messageTree.d.ts +25 -0
  235. package/packages/server/dist/utils/messageTree.d.ts.map +1 -1
  236. package/packages/server/dist/utils/messageTree.js +167 -3
  237. package/packages/server/dist/utils/messageTree.js.map +1 -1
  238. package/packages/server/dist/utils/pathUtils.d.ts.map +1 -1
  239. package/packages/server/dist/utils/pathUtils.js +1 -0
  240. package/packages/server/dist/utils/pathUtils.js.map +1 -1
  241. package/packages/server/dist/utils/residencyActivation.d.ts +61 -0
  242. package/packages/server/dist/utils/residencyActivation.d.ts.map +1 -0
  243. package/packages/server/dist/utils/residencyActivation.js +60 -0
  244. package/packages/server/dist/utils/residencyActivation.js.map +1 -0
  245. package/packages/server/dist/utils/residencyPolicy.d.ts +62 -0
  246. package/packages/server/dist/utils/residencyPolicy.d.ts.map +1 -0
  247. package/packages/server/dist/utils/residencyPolicy.js +82 -0
  248. package/packages/server/dist/utils/residencyPolicy.js.map +1 -0
  249. package/packages/server/dist/utils/runtimeParamStrategy.d.ts +51 -0
  250. package/packages/server/dist/utils/runtimeParamStrategy.d.ts.map +1 -0
  251. package/packages/server/dist/utils/runtimeParamStrategy.js +60 -0
  252. package/packages/server/dist/utils/runtimeParamStrategy.js.map +1 -0
  253. package/packages/server/dist/utils/stopStrategy.d.ts +62 -0
  254. package/packages/server/dist/utils/stopStrategy.d.ts.map +1 -0
  255. package/packages/server/dist/utils/stopStrategy.js +65 -0
  256. package/packages/server/dist/utils/stopStrategy.js.map +1 -0
  257. package/packages/server/resources/internals/INDEX.md +1 -1
  258. package/packages/server/resources/internals/image-storage.md +14 -2
  259. package/packages/server/resources/manual/02-chat.md +30 -13
  260. package/packages/server/resources/manual/03-sessions.md +22 -1
  261. package/packages/server/resources/manual/04-slash-commands-favorites.md +2 -1
  262. package/packages/server/resources/manual/05-projects.md +1 -0
  263. package/packages/server/resources/manual/06-file-explorer-editor.md +21 -2
  264. package/packages/server/resources/manual/07-git.md +1 -1
  265. package/packages/server/resources/manual/08-terminal.md +1 -1
  266. package/packages/server/resources/manual/12-harness-workbench.md +21 -16
  267. package/packages/server/resources/manual/13-settings.md +22 -6
  268. package/packages/server/resources/manual/14-keyboard-shortcuts.md +8 -1
  269. package/packages/shared/dist/constants/messageTree.d.ts +2 -0
  270. package/packages/shared/dist/constants/messageTree.d.ts.map +1 -1
  271. package/packages/shared/dist/constants/messageTree.js +6 -0
  272. package/packages/shared/dist/constants/messageTree.js.map +1 -1
  273. package/packages/shared/dist/index.d.ts +1 -1
  274. package/packages/shared/dist/index.d.ts.map +1 -1
  275. package/packages/shared/dist/index.js.map +1 -1
  276. package/packages/shared/dist/types/accounts.d.ts +13 -0
  277. package/packages/shared/dist/types/accounts.d.ts.map +1 -1
  278. package/packages/shared/dist/types/history.d.ts +14 -1
  279. package/packages/shared/dist/types/history.d.ts.map +1 -1
  280. package/packages/shared/dist/types/message.d.ts +26 -3
  281. package/packages/shared/dist/types/message.d.ts.map +1 -1
  282. package/packages/shared/dist/types/message.js +21 -1
  283. package/packages/shared/dist/types/message.js.map +1 -1
  284. package/packages/shared/dist/types/preferences.d.ts +5 -1
  285. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  286. package/packages/shared/dist/types/preferences.js +3 -0
  287. package/packages/shared/dist/types/preferences.js.map +1 -1
  288. package/packages/shared/dist/types/sdk.d.ts +68 -10
  289. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  290. package/packages/shared/dist/types/sdk.js +56 -16
  291. package/packages/shared/dist/types/sdk.js.map +1 -1
  292. package/packages/shared/dist/types/session.d.ts +6 -0
  293. package/packages/shared/dist/types/session.d.ts.map +1 -1
  294. package/packages/shared/dist/types/session.js +6 -0
  295. package/packages/shared/dist/types/session.js.map +1 -1
  296. package/packages/shared/dist/types/streaming.d.ts +32 -2
  297. package/packages/shared/dist/types/streaming.d.ts.map +1 -1
  298. package/packages/shared/dist/types/streaming.js.map +1 -1
  299. package/packages/shared/dist/types/websocket.d.ts +4 -0
  300. package/packages/shared/dist/types/websocket.d.ts.map +1 -1
  301. package/scripts/probe-persistent-hooks.mjs +795 -0
  302. package/packages/client/dist/assets/index-BHbx57gc.js +0 -2
  303. package/packages/client/dist/assets/index-BX7y_Qt3.css +0 -32
  304. package/packages/client/dist/assets/index-DWIOf7rA.js +0 -1657
@@ -7,11 +7,15 @@
7
7
  import { Server as SocketIOServer } from 'socket.io';
8
8
  import { existsSync, unlinkSync, readFileSync, readdirSync, statSync } from 'fs';
9
9
  import { randomUUID } from 'crypto';
10
- import { ERROR_CODES, IMAGE_CONSTRAINTS, parseQueueScript, TERMINAL_ERRORS, ROOT_BRANCH_KEY, effectiveModelIs1M } from '@hammoc/shared';
10
+ import { ERROR_CODES, IMAGE_CONSTRAINTS, PDF_CONSTRAINTS, ATTACHMENT_TOTAL_MAX_SIZE_BYTES, parseQueueScript, TERMINAL_ERRORS, ROOT_BRANCH_KEY, effectiveModelIs1M, DEFAULT_PREFERENCES } from '@hammoc/shared';
11
11
  import i18next from '../i18n.js';
12
12
  import { SUPPORTED_LANGUAGES } from '@hammoc/shared';
13
13
  import { isLocalIP, extractClientIP } from '../utils/networkUtils.js';
14
+ import { denyBackgroundBash } from '../utils/backgroundToolGuard.js';
14
15
  import { createChatEngine } from '../services/chatEngineFactory.js';
16
+ import { EngineResidencyRegistry } from '../services/engineResidencyRegistry.js';
17
+ import { normalizeResidencyConfig } from '../utils/residencyPolicy.js';
18
+ import { shouldReuseResidentEngine, shouldRegisterOnCreate } from '../utils/residencyActivation.js';
15
19
  import { SessionService, sessionService } from '../services/sessionService.js';
16
20
  import { parseSDKError, AbortedError, SDKErrorCode } from '../utils/errors.js';
17
21
  import { createSessionMiddleware } from '../middleware/session.js';
@@ -22,6 +26,7 @@ import { getOrCreateQueueService, getQueueInstances } from '../controllers/queue
22
26
  import { createLogger } from '../utils/logger.js';
23
27
  import { clampEffortForModel, supportsAdaptiveThinking } from '../utils/effortUtils.js';
24
28
  import { shouldForwardCliProgress, shouldForwardCliPtyMirror } from '../utils/cliEngineUtils.js';
29
+ import { resolveStopStrategy, shouldSuppressResultError, shouldNotifyComplete, shouldRetryResumeAfterError, shouldWaitForBackgroundTasks, } from '../utils/stopStrategy.js';
25
30
  import { getCliScreen, getCliScreenStall, setCliScreenStall, deleteCliScreen } from '../services/cliScreenCache.js';
26
31
  import { createScreenStallWatchdog } from '../services/cliScreenStallWatchdog.js';
27
32
  import { modelMissingNative1MSupport } from '../utils/bundledBinaryModelSupport.js';
@@ -35,7 +40,8 @@ import { dashboardService } from '../services/dashboardService.js';
35
40
  import { fileWatcherService } from '../services/fileWatcherService.js';
36
41
  import { summarize } from '../services/summarizeService.js';
37
42
  import { parseJSONLFile, transformToHistoryMessages } from '../services/historyParser.js';
38
- import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, findBranchSelectionsForUuid } from '../utils/messageTree.js';
43
+ import { waitForBackgroundTasksAndFollowUp } from '../utils/backgroundTaskPoller.js';
44
+ import { buildRawMessageTree, getActiveRawBranch, getDefaultRawBranchSelections, findBranchSelectionsForUuid, repairPostCompactParenting } from '../utils/messageTree.js';
39
45
  import { sessionBufferManager } from '../services/sessionBufferManager.js';
40
46
  import { imageStorageService } from '../services/imageStorageService.js';
41
47
  import { isSnippetRef, resolveSnippet, listSnippets, SnippetError } from '../utils/snippetResolver.js';
@@ -103,6 +109,74 @@ let connectedClients = 0;
103
109
  // Primary maps: sessionId → ActiveStream, socketId → sessionId
104
110
  const activeStreams = new Map();
105
111
  const socketToSession = new Map();
112
+ // ── Story 38.7: engine residency ACTIVATION ──────────────────────────────────
113
+ // Turn-boundary process residency (Story 38.5 delivered the registry + resource
114
+ // policy as a "ready seam"; this story wires it into the turn flow). One SDK engine
115
+ // process is kept alive per active session so a follow-up turn REUSES it (no ~3s
116
+ // re-spawn) instead of re-creating one. It is ALWAYS activated together with the
117
+ // bounds (idle sweep + LRU cap) so idle sessions cannot accumulate processes
118
+ // ("무정책 상주 금지"); the CLI (PTY) lifecycle is untouched (SDK-engine only).
119
+ //
120
+ // The registry's getConfig must be SYNCHRONOUS (register/sweep call it inline), but
121
+ // preferences load async — so we hold a snapshot refreshed from the preferences
122
+ // `handleChatSend` already reads every send (+ once at bootstrap). A missing/absent
123
+ // pref falls back to the DEFAULT (never 0 → the sweep/cap can never be silently
124
+ // disabled by an old preferences file that predates these keys).
125
+ let residencyConfig = normalizeResidencyConfig({
126
+ idleTimeoutMs: DEFAULT_PREFERENCES.sdkIdleTimeoutMs,
127
+ maxEngines: DEFAULT_PREFERENCES.sdkMaxResidentEngines,
128
+ });
129
+ function refreshResidencyConfig(prefs) {
130
+ residencyConfig = normalizeResidencyConfig({
131
+ idleTimeoutMs: prefs.sdkIdleTimeoutMs ?? DEFAULT_PREFERENCES.sdkIdleTimeoutMs,
132
+ maxEngines: prefs.sdkMaxResidentEngines ?? DEFAULT_PREFERENCES.sdkMaxResidentEngines,
133
+ });
134
+ }
135
+ // The third arg lets the idle sweep / LRU eviction see which sessions have a turn
136
+ // in progress (status === 'running') and skip them: a mid-flight turn is alive even
137
+ // when its activity clock is stale (the clock ticks only at turn start), so without
138
+ // this guard a turn outliving the idle timeout — a slow tool run, or an
139
+ // AskUserQuestion awaiting the user — would be evicted while streaming and killed.
140
+ const engineResidency = new EngineResidencyRegistry(() => residencyConfig, undefined, (sessionId) => activeStreams.get(sessionId)?.status === 'running');
141
+ /**
142
+ * Server shutdown hook: stop the idle sweep and close every resident SDK engine
143
+ * (clean control-channel teardown). Idempotent; safe if residency never started.
144
+ */
145
+ export async function shutdownEngineResidency() {
146
+ await engineResidency.stop();
147
+ }
148
+ /**
149
+ * Evict + close the resident SDK engine for `sessionId`, if any (idempotent,
150
+ * no-op on a miss). For OUT-OF-BAND writers on a session's JSONL — the queue
151
+ * executor resuming a session, or session deletion. A parked resident engine
152
+ * keeps the conversation in PROCESS MEMORY; letting another engine append turns
153
+ * to the same JSONL would fork the on-disk history from what the resident
154
+ * engine believes, so the writer must evict first (the next chat send then
155
+ * resumes fresh from JSONL and sees the appended turns).
156
+ */
157
+ export async function evictResidentEngine(sessionId) {
158
+ await engineResidency.remove(sessionId);
159
+ }
160
+ /**
161
+ * Whether a session JSONL already holds real conversation turns (same heuristic
162
+ * as the phantom-checkpoint cleanup in handleChatSend's finally). The resume-retry
163
+ * branch DELETES the target file before retrying fresh (clears "Session ID already
164
+ * in use") — that recovery is designed for the conversation-less stub an aborted
165
+ * first send leaves behind. A file with real history must never be wiped by it
166
+ * (e.g. a resident engine dying between turns fails the resume the same way).
167
+ * Fail-safe: an unreadable file is treated as real history — never delete blind.
168
+ */
169
+ function sessionFileHasConversation(filePath) {
170
+ try {
171
+ if (!existsSync(filePath))
172
+ return false;
173
+ const raw = readFileSync(filePath, 'utf8');
174
+ return raw.includes('"type":"user"') || raw.includes('"type":"assistant"');
175
+ }
176
+ catch {
177
+ return true;
178
+ }
179
+ }
106
180
  // Story 24.3: Track which session room each socket joined (for session:leave room management)
107
181
  const socketSessionRoom = new Map();
108
182
  // Track which project room each socket joined (for leave on session switch)
@@ -296,6 +370,15 @@ function scheduleChainDrain(sessionId, lang) {
296
370
  const drainSuccess = await handleChatSend(stream, { content: nextItem.content, workingDirectory, sessionId, resume: chainResumableSessions.has(sessionId) || undefined, permissionMode, model, effort }, abortController, lang);
297
371
  if (!drainSuccess)
298
372
  throw new Error('handleChatSend returned false');
373
+ // Background tasks pending at end_turn hold the chain too — advancing
374
+ // now would start the next item while this one is still working.
375
+ // (markMainEnded here for the same reason as the chat:send path: the
376
+ // browser onComplete replacement swallows the streamCallbacks-level one.)
377
+ stream.backgroundTracker?.markMainEnded();
378
+ if (stream.backgroundTracker?.isWaiting) {
379
+ const drainBgSlug = sessionProjectMap.get(sessionId) ?? null;
380
+ await waitForBackgroundTasks(stream.backgroundTracker, sessionId, drainBgSlug, abortController.signal, stream.streamCallbacks);
381
+ }
299
382
  // Success: mark as 'sent' and remove from chain
300
383
  nextItem.status = 'sent';
301
384
  chainResumableSessions.add(sessionId);
@@ -418,11 +501,18 @@ function createStreamEmit(stream) {
418
501
  }
419
502
  };
420
503
  }
421
- /** Get session IDs of all currently running active streams */
422
- export function getActiveStreamSessionIds() {
423
- return [...activeStreams.entries()]
424
- .filter(([, stream]) => stream.status === 'running')
425
- .map(([key]) => key);
504
+ /**
505
+ * Session ids of all currently running streams mapped to their turn start time (ms).
506
+ * The session list uses this to treat a streaming session's "last activity" as live —
507
+ * the on-disk index timestamp is only refreshed at turn end and is stale mid-turn.
508
+ */
509
+ export function getActiveStreamStartTimes() {
510
+ const out = new Map();
511
+ for (const [key, stream] of activeStreams.entries()) {
512
+ if (stream.status === 'running')
513
+ out.set(key, stream.startedAt);
514
+ }
515
+ return out;
426
516
  }
427
517
  /**
428
518
  * Running streams for a project, with their captured first prompt. Used by the
@@ -449,7 +539,9 @@ export function getActiveSessionCountsByProject() {
449
539
  for (const [sessionId, stream] of activeStreams.entries()) {
450
540
  if (stream.status !== 'running')
451
541
  continue;
452
- const slug = sessionProjectMap.get(sessionId);
542
+ // Prefer the synchronously-captured stream.projectSlug — sessionProjectMap is
543
+ // populated async and misses streams that just started (same as meta lookup).
544
+ const slug = stream.projectSlug ?? sessionProjectMap.get(sessionId);
453
545
  if (slug) {
454
546
  counts.set(slug, (counts.get(slug) ?? 0) + 1);
455
547
  }
@@ -593,6 +685,34 @@ async function pollFileStabilityThenReload(sessionId, projectSlug) {
593
685
  log.info(`pollFileStability: session=${sessionId}, elapsed=${Date.now() - startedAt}ms, stable=${stableCount >= FILE_POLL_STABLE_COUNT}`);
594
686
  return sessionBufferManager.reloadFromJSONL(sessionId, projectSlug);
595
687
  }
688
+ /**
689
+ * Wait for all background tasks to complete by watching the session JSONL.
690
+ * The engine returns from `query()` at end_turn while background tasks
691
+ * (Bash/Agent run_in_background, engine-initiated async agents) are still
692
+ * running, then auto-resumes generation in the same session when they finish.
693
+ * The shared poller resolves pendings, registers launches that happen inside
694
+ * follow-up generations (e.g. SendMessage re-engaging an agent), and holds the
695
+ * turn open until the follow-up reaches end_turn.
696
+ *
697
+ * @returns true when settled, false on abort/timeout
698
+ */
699
+ async function waitForBackgroundTasks(tracker, sessionId, projectSlug, abortSignal, callbacks) {
700
+ if (!tracker.isWaiting)
701
+ return true;
702
+ const slug = projectSlug ?? new SessionService().encodeProjectPath('');
703
+ if (!slug) {
704
+ log.warn(`[BG-WAIT] no projectSlug for session ${sessionId}, cannot poll JSONL — skipping wait`);
705
+ return false;
706
+ }
707
+ const filePath = new SessionService().getSessionFilePath(slug, sessionId);
708
+ return waitForBackgroundTasksAndFollowUp({
709
+ filePath,
710
+ tracker,
711
+ abortSignal,
712
+ callbacks,
713
+ logPrefix: `[BG-WAIT ${sessionId.slice(0, 8)}]`,
714
+ });
715
+ }
596
716
  /** Clean up a stream from activeStreams immediately.
597
717
  * Story 27.1: SessionBufferManager retains the messages; no completedBuffer needed. */
598
718
  function cleanupStream(streamKey, expectedStream) {
@@ -619,6 +739,40 @@ function cleanupStream(streamKey, expectedStream) {
619
739
  }
620
740
  }
621
741
  }
742
+ /**
743
+ * Story 38.7: stop a running turn's generation on `stream`, routing to the primitive
744
+ * that actually reaches the engine.
745
+ *
746
+ * On a RESIDENT SDK engine the per-turn abortController is not wired to the
747
+ * (turn-1-created) live query, and `processStream` never polls the abort signal — so a
748
+ * reused turn-2+ generation can ONLY be stopped by `interrupt()` (turn-only; the
749
+ * session/generator survives so the next send continues). A hard abort is kept for the
750
+ * CLI engine (PTY Ctrl+C rides the abortController) or an SDK engine whose query isn't
751
+ * up yet (`resolveStopStrategy` decides). If interrupt() throws on a resident engine —
752
+ * where abort cannot reach its live query — the engine is closed via the registry to
753
+ * unstick the parked stream and force a fresh resume on the next send.
754
+ *
755
+ * The CALLER sets the outcome flag BEFORE calling: `userInterrupted` for a
756
+ * keep-the-session stop (Stop button, chain cancel) or `timedOut` for a
757
+ * treat-as-failure stop (inactivity timeout). This function only decides HOW
758
+ * generation stops, not the turn's success / eviction outcome.
759
+ */
760
+ function requestTurnStop(stream, reason) {
761
+ const svc = stream.chatService;
762
+ const strategy = resolveStopStrategy({ engineMode: stream.engineMode, canInterrupt: !!svc?.interrupt });
763
+ if (strategy === 'interrupt') {
764
+ Promise.resolve(svc.interrupt()).catch((err) => {
765
+ log.warn(`interrupt() failed for session ${stream.sessionId} (${reason}), hard aborting + evicting: ${err instanceof Error ? err.message : String(err)}`);
766
+ stream.abortController.abort(reason);
767
+ // A resident live query is unreachable by abort — close it so the parked stream
768
+ // unwinds and the next send resumes fresh from JSONL (crash-reconnect path).
769
+ void engineResidency.remove(stream.sessionId);
770
+ });
771
+ }
772
+ else {
773
+ stream.abortController.abort(reason);
774
+ }
775
+ }
622
776
  /** Normalize legacy 'never' sync policy to 'streaming' */
623
777
  function normalizeSyncPolicy(policy) {
624
778
  return policy === 'streaming' ? 'streaming' : 'always';
@@ -711,6 +865,24 @@ export function rekeyStream(stream, newSessionId) {
711
865
  sock.leave(`session:${oldSessionId}`);
712
866
  sock.join(`session:${newSessionId}`);
713
867
  }
868
+ // Story 38.7: mirror the residency entry onto the SDK-assigned id.
869
+ // · fork — a fork is a NEW query for a NEW session; register it under the assigned
870
+ // id WITHOUT disturbing the ORIGINAL session's resident entry (rekey would move
871
+ // the original's engine, which must stay independent). Registering here (during
872
+ // the send, before the callback path's turn-end close) is also what makes the
873
+ // fork engine resident so it survives into the forked session's next turn.
874
+ // · non-fork — the SDK confirmed a (possibly different) id for the SAME session;
875
+ // move the resident entry from the old key to the new one.
876
+ // No-op for a CLI engine (register is a feature-detect no-op; nothing registered
877
+ // under the old key for rekey to move).
878
+ if (stream.chatService && stream.engineMode === 'sdk') {
879
+ if (stream.isFork) {
880
+ void engineResidency.register(newSessionId, stream.chatService, stream.engineMode);
881
+ }
882
+ else {
883
+ engineResidency.rekey(oldSessionId, newSessionId);
884
+ }
885
+ }
714
886
  }
715
887
  /**
716
888
  * Mark a stream as completed and broadcast stream-change.
@@ -776,6 +948,27 @@ function emitQuestionChange(sessionId, hasPendingQuestion) {
776
948
  triggerDashboardStatusChange(projectSlug);
777
949
  }
778
950
  }
951
+ /**
952
+ * Story 38.3: settle and clear every pending permission / AskUserQuestion for a
953
+ * stream. On Stop (interrupt or abort) the `canUseTool` gate may be parked awaiting a
954
+ * user response that will now never come — resolving each as "denied" unblocks that
955
+ * awaited promise (so the SDK isn't left waiting on a permission), and clearing the
956
+ * map prevents a leak. Also clears the question badge. No-op when nothing is pending.
957
+ */
958
+ function settleStreamPermissions(stream, sessionId) {
959
+ if (stream.pendingPermissions.size === 0)
960
+ return;
961
+ for (const pending of stream.pendingPermissions.values()) {
962
+ try {
963
+ pending.resolve({ approved: false });
964
+ }
965
+ catch {
966
+ /* already settled */
967
+ }
968
+ }
969
+ stream.pendingPermissions.clear();
970
+ emitQuestionChange(sessionId, false);
971
+ }
779
972
  /**
780
973
  * Broadcast session:stream-change to project room (or all clients as fallback).
781
974
  * Used by queue service to signal stream start/end.
@@ -819,6 +1012,17 @@ export async function initializeWebSocket(httpServer) {
819
1012
  cors: config.cors,
820
1013
  maxHttpBufferSize: 100 * 1024 * 1024, // 100MB for base64 image payloads
821
1014
  });
1015
+ // Story 38.7: seed the residency config from disk and begin the periodic idle
1016
+ // sweep. Activation without the sweep would let resident engines accumulate
1017
+ // unbounded — starting it here is the mandatory other half of "무정책 상주 금지".
1018
+ // The sweep timer is unref'd (never keeps the process alive on its own).
1019
+ try {
1020
+ refreshResidencyConfig(await preferencesService.getEffectivePreferences());
1021
+ }
1022
+ catch {
1023
+ // Fall back to the DEFAULT-derived config already in `residencyConfig`.
1024
+ }
1025
+ engineResidency.start();
822
1026
  // Mirror project-room membership into fileWatcherService so every code path
823
1027
  // that calls socket.join/leave with a `project:<slug>` room transparently
824
1028
  // starts or stops the chokidar watcher for that project. socket.io emits
@@ -932,13 +1136,20 @@ export async function initializeWebSocket(httpServer) {
932
1136
  });
933
1137
  // Start rate limit polling on first client connection
934
1138
  if (connectedClients === 1) {
935
- rateLimitProbeService.startPolling((data) => { io.emit('rateLimit:update', data); }, (data) => { io.emit('apiHealth:update', data); });
1139
+ rateLimitProbeService.startPolling((data) => { io.emit('rateLimit:update', data); }, (data) => { io.emit('apiHealth:update', data); }, (throttledUntil) => { io.emit('rateLimit:throttled', { throttledUntil }); });
936
1140
  }
937
1141
  // Send cached rate limit data immediately to newly connected client
938
1142
  const cachedRateLimit = rateLimitProbeService.getCachedResult();
939
1143
  if (cachedRateLimit) {
940
1144
  socket.emit('rateLimit:update', cachedRateLimit);
941
1145
  }
1146
+ // Inside a usage-API Retry-After window the cache is empty and no rateLimit:update can
1147
+ // arrive until the recovery poll — tell the connecting client honestly why the bars are
1148
+ // empty instead of leaving it on "checking…".
1149
+ const usageThrottledUntil = rateLimitProbeService.getThrottledUntil();
1150
+ if (usageThrottledUntil) {
1151
+ socket.emit('rateLimit:throttled', { throttledUntil: usageThrottledUntil });
1152
+ }
942
1153
  // Send cached API health status to newly connected client
943
1154
  const cachedHealth = rateLimitProbeService.getApiHealth();
944
1155
  if (cachedHealth) {
@@ -972,6 +1183,13 @@ export async function initializeWebSocket(httpServer) {
972
1183
  }
973
1184
  }
974
1185
  existingStream.abortController.abort('another-client');
1186
+ // Story 38.7: a resident engine's live query is unreachable by the old turn's
1187
+ // abort (and this replacement send is about to build a new stream on the SAME
1188
+ // session key). Evict + close the old resident SYNCHRONOUSLY (the map delete is
1189
+ // synchronous) so the replacement's registry.get() misses and creates a fresh,
1190
+ // resuming engine — avoiding two turns racing one shared iterator. The close
1191
+ // also unwinds the old turn's parked stream.
1192
+ void engineResidency.remove(streamKey);
975
1193
  }
976
1194
  // Ensure this socket is in the session room so that chain:add events
977
1195
  // emitted right after chat:send pass the room membership check.
@@ -1030,6 +1248,32 @@ export async function initializeWebSocket(httpServer) {
1030
1248
  const sendSuccess = await handleChatSend(stream, data, abortController, lang);
1031
1249
  if (sendSuccess)
1032
1250
  chainResumableSessions.add(stream.sessionId);
1251
+ // Hammoc invariant: end_turn = done. If background tasks are pending
1252
+ // when the stream ends (run_in_background OR the engine deciding to
1253
+ // background a subagent on its own), the SDK returns at end_turn while
1254
+ // those tasks keep running, then auto-resumes into the JSONL. Wait for
1255
+ // completion AND the auto-resume follow-up before declaring the turn
1256
+ // done — otherwise queue/chain advance prematurely, session status
1257
+ // flips to idle, and completion notifications fire early. The stream
1258
+ // stays in activeStreams during the wait, blocking chain drain or
1259
+ // replacement.
1260
+ //
1261
+ // markMainEnded must happen HERE: the browser path replaces
1262
+ // callbacks.onComplete (completion deferral), so the streamCallbacks-
1263
+ // level markMainEnded never fires on this path. Gated on sendSuccess —
1264
+ // after an error/abort the engine process is not trustworthy to ever
1265
+ // deliver a completion notification, so waiting would only hang.
1266
+ if (sendSuccess)
1267
+ stream.backgroundTracker?.markMainEnded();
1268
+ // Story 38.3: a user interrupt (Stop) means "stop now" — do NOT hold the turn
1269
+ // waiting on background tasks. The hard-abort path already short-circuits this
1270
+ // via its aborted signal; the interrupt path leaves the signal unset, so gate
1271
+ // on the flag explicitly.
1272
+ const bgTracker = stream.backgroundTracker;
1273
+ if (shouldWaitForBackgroundTasks(!!stream.userInterrupted, !!bgTracker?.isWaiting)) {
1274
+ const bgSlug = sessionProjectMap.get(stream.sessionId) ?? null;
1275
+ await waitForBackgroundTasks(bgTracker, stream.sessionId, bgSlug, abortController.signal, stream.streamCallbacks);
1276
+ }
1033
1277
  }
1034
1278
  finally {
1035
1279
  stream.status = 'completed';
@@ -1117,20 +1361,40 @@ export async function initializeWebSocket(httpServer) {
1117
1361
  socket.emit('permission:already-resolved', { requestId: data.requestId });
1118
1362
  }
1119
1363
  });
1120
- // Handle chat:abort event — find stream and abort, notify all viewers
1364
+ // Handle chat:abort event — the Stop button. Story 38.3 splits the single
1365
+ // "abort the whole SDK stream" path into two:
1366
+ // • SDK engine → INTERRUPT (turn-only): stop just this turn's generation via
1367
+ // the persistent query's interrupt(), keeping the session/generator alive so
1368
+ // the conversation can continue (the point of Epic 38). The interrupted turn
1369
+ // ends with an error result, which handleChatSend suppresses (userInterrupted).
1370
+ // • CLI engine → ABORT (unchanged): the PTY Ctrl+C path runs off the
1371
+ // abortController signal, so CLI keeps aborting the controller as before.
1372
+ // The wire event is unchanged (no new field) — the split is server-internal, and
1373
+ // the observable outcome (streaming stops for every viewer) is preserved. Explicit
1374
+ // session teardown (close()) still flows through the existing teardown events
1375
+ // (another-client / chain-cleared / disconnect); higher-level session lifecycle is
1376
+ // Story 38.5.
1121
1377
  socket.on('chat:abort', () => {
1122
1378
  const sessionId = socketToSession.get(socket.id);
1123
1379
  if (!sessionId)
1124
1380
  return;
1125
1381
  const stream = activeStreams.get(sessionId);
1126
1382
  if (stream && stream.status === 'running') {
1127
- // Emit abort notification to all connected viewers BEFORE triggering abort.
1128
- // This ensures passive viewers (who didn't initiate the abort) also
1129
- // receive a completion signal, preventing them from being stuck in isStreaming=true.
1383
+ // Emit stop notification to all connected viewers BEFORE triggering the stop.
1384
+ // This ensures passive viewers (who didn't initiate it) also receive a
1385
+ // completion signal, preventing them from being stuck in isStreaming=true.
1130
1386
  for (const sock of stream.sockets) {
1131
1387
  sock.emit('stream:detached', { sessionId, reason: 'user-abort' });
1132
1388
  }
1133
- stream.abortController.abort('user-abort');
1389
+ // Settle any parked permission/AskUserQuestion first so canUseTool unblocks
1390
+ // and nothing leaks — for both engines.
1391
+ settleStreamPermissions(stream, sessionId);
1392
+ // Story 38.3 AC1 / 38.7: mark the turn as a user stop (its interrupt error
1393
+ // result is suppressed) and route to interrupt (SDK, turn-only — the session
1394
+ // survives so it can continue) or a hard abort (CLI / boot window). On a
1395
+ // resident SDK engine this is the ONLY way to reach the reused live query.
1396
+ stream.userInterrupted = true;
1397
+ requestTurnStop(stream, 'user-abort');
1134
1398
  }
1135
1399
  // Also clear any pending prompt chain — user expects everything to stop.
1136
1400
  // Always bump generation to invalidate any in-flight drain timers,
@@ -1625,7 +1889,11 @@ export async function initializeWebSocket(httpServer) {
1625
1889
  if (removedItem?.status === 'sending') {
1626
1890
  const stream = activeStreams.get(sessionId);
1627
1891
  if (stream && stream.status === 'running') {
1628
- stream.abortController.abort('chain-item-removed');
1892
+ // Story 38.7: on a resident SDK engine the abortController can't reach the
1893
+ // reused live query — route through interrupt() (turn-only, session kept),
1894
+ // marking it as an intended stop so no error card appears (matches Stop).
1895
+ stream.userInterrupted = true;
1896
+ requestTurnStop(stream, 'chain-item-removed');
1629
1897
  }
1630
1898
  }
1631
1899
  const filtered = items.filter(item => item.id !== id);
@@ -1687,7 +1955,10 @@ export async function initializeWebSocket(httpServer) {
1687
1955
  if (items?.some(item => item.status === 'sending')) {
1688
1956
  const stream = activeStreams.get(sessionId);
1689
1957
  if (stream && stream.status === 'running') {
1690
- stream.abortController.abort('chain-cleared');
1958
+ // Story 38.7: interrupt (turn-only) on a resident SDK engine — abort can't
1959
+ // reach its reused live query. Marked as an intended stop (no error card).
1960
+ stream.userInterrupted = true;
1961
+ requestTurnStop(stream, 'chain-cleared');
1691
1962
  }
1692
1963
  }
1693
1964
  chainState.set(sessionId, []);
@@ -1726,11 +1997,29 @@ export async function initializeWebSocket(httpServer) {
1726
1997
  return;
1727
1998
  log.info(`session:rewind-files sessionId=${sessionId}, messageUuid=${messageUuid}, dryRun=${!!dryRun}`);
1728
1999
  try {
1729
- // Standalone rewind is now a ChatEngine operation (Story 32.3) — the SDK
1730
- // mechanism (throwaway resume query + markRewindDirty + close) lives
1731
- // behind the seam in ChatService.rewindFiles. The handler keeps only the
1732
- // transport concerns: result → session:rewind-result mapping.
1733
- const engine = createChatEngine(await projectService.getEffectiveEngineMode(workingDirectory), { workingDirectory });
2000
+ // Standalone rewind is a ChatEngine operation (Story 32.3) — the SDK mechanism
2001
+ // (throwaway resume query + markRewindDirty + close) lives behind the seam in
2002
+ // ChatService.rewindFiles. The handler keeps only the transport concerns.
2003
+ //
2004
+ // A rewind restores project files while a turn is streaming it would race
2005
+ // the in-flight generation's own file writes (and the standalone path would
2006
+ // spin a SECOND live query on the same session's JSONL). The UI already
2007
+ // disables the button during streaming; this is the server-side guard for
2008
+ // late tabs / raced clicks. Reject instead of proceeding with a throwaway.
2009
+ if (activeStreams.get(sessionId)?.status === 'running') {
2010
+ socket.emit('session:rewind-result', {
2011
+ success: false,
2012
+ dryRun: !!dryRun,
2013
+ error: t('ws.error.rewindWhileStreaming'),
2014
+ });
2015
+ return;
2016
+ }
2017
+ // Story 38.7: if a LIVE resident engine holds this session, rewind INLINE on
2018
+ // its persistent query (ChatService.rewindFiles reuses currentQuery) — no
2019
+ // second live query on the same session's JSONL. Otherwise use a fresh
2020
+ // throwaway engine (the standalone path).
2021
+ const resident = engineResidency.get(sessionId);
2022
+ const engine = resident ?? createChatEngine(await projectService.getEffectiveEngineMode(workingDirectory), { workingDirectory });
1734
2023
  const result = await engine.rewindFiles({ sessionId, messageUuid, dryRun: !!dryRun });
1735
2024
  if (result.canRewind) {
1736
2025
  socket.emit('session:rewind-result', {
@@ -2220,6 +2509,11 @@ export async function initializeWebSocket(httpServer) {
2220
2509
  prior.dispose();
2221
2510
  socketLoginSessions.delete(socket.id);
2222
2511
  }
2512
+ // Snapshot the current active account's credential BEFORE the login PTY overwrites the
2513
+ // credentials file with the new account. The CLI continuously refreshes the active token
2514
+ // (new accessToken + possibly rotated refreshToken), but accounts.json still holds the
2515
+ // copy from original login. Without this, switching back would use a stale/revoked token.
2516
+ accountStorageService.resnapshotAndSave().catch(() => { });
2223
2517
  // The login PTY uses the same binary-path override as chat (global preference).
2224
2518
  let cliBinaryPath;
2225
2519
  try {
@@ -2232,15 +2526,15 @@ export async function initializeWebSocket(httpServer) {
2232
2526
  onMethodPrompt: () => socket.emit('auth:method-prompt'),
2233
2527
  onUrl: (url) => socket.emit('auth:url', { url }),
2234
2528
  onCodePrompt: () => socket.emit('auth:code-prompt'),
2235
- onComplete: (account) => {
2236
- socket.emit('auth:complete', { account });
2529
+ onComplete: async (account) => {
2237
2530
  socketLoginSessions.delete(socket.id);
2238
- // Story BS-8: accumulate the just-written credential into the multi-account store.
2239
- // Login already made this account active, so the credentials file holds its credential
2240
- // and accountInfoService supplied the email (or null → fallback key, AC1a). Fire-and-forget.
2241
- accountStorageService.captureActiveCredential(account?.email ?? null).catch((err) => {
2531
+ try {
2532
+ await accountStorageService.captureActiveCredential(account?.email ?? null);
2533
+ }
2534
+ catch (err) {
2242
2535
  log.warn(`BS-8: failed to capture credential after login: ${err instanceof Error ? err.message : String(err)}`);
2243
- });
2536
+ }
2537
+ socket.emit('auth:complete', { account });
2244
2538
  },
2245
2539
  onError: (message) => {
2246
2540
  socket.emit('auth:error', { message });
@@ -2264,11 +2558,24 @@ export async function initializeWebSocket(httpServer) {
2264
2558
  socket.emit('auth:error', { message: 'Failed to delete credentials' });
2265
2559
  return;
2266
2560
  }
2267
- // Refresh the in-memory account cache so a subsequent /account read reflects the logout.
2268
- // The client transitions to the inline login flow optimistically (no success event needed).
2269
- accountInfoService.refresh().catch(() => {
2270
- /* best-effort the file is gone regardless */
2271
- });
2561
+ // Also drop the CLI's identity cache (`oauthAccount` in `~/.claude.json`): it is written at
2562
+ // login and NOT tied to the credential file, so after deleting the credential it would keep
2563
+ // reporting the last login's identity (phantom logged-in account).
2564
+ await accountStorageService.clearCliIdentity().catch(() => { });
2565
+ // BS-8: the multi-account active marker pointed at the credential we just deleted — clear it
2566
+ // (but KEEP the stored accounts so the user can switch back). Then clear the in-memory account
2567
+ // cache DIRECTLY rather than re-running a bootstrap query: the SDK reports a logged-out user as
2568
+ // a non-null `{ tokenSource: 'none' }` stub, so a refresh would re-cache a phantom account and
2569
+ // the UI would show an empty card instead of the login flow.
2570
+ await accountStorageService.setActiveKey(null).catch(() => { });
2571
+ accountInfoService.clear();
2572
+ // Propagate logout to every tab/device so they drop to the login flow too.
2573
+ try {
2574
+ io.emit('account:logged-out');
2575
+ }
2576
+ catch {
2577
+ /* io not ready (e.g. tests) — the originating tab transitions optimistically anyway */
2578
+ }
2272
2579
  });
2273
2580
  // Disconnect: detach socket from stream, DON'T abort or deny permissions
2274
2581
  socket.on('disconnect', (reason) => {
@@ -2440,23 +2747,40 @@ export function getConnectedClientsCount() {
2440
2747
  return connectedClients;
2441
2748
  }
2442
2749
  /**
2443
- * Validate image attachments
2444
- * Story 5.5: Image Attachment
2750
+ * Validate attachments (images + PDFs, distinguished by mimeType)
2751
+ * Story 5.5: Image Attachment / Story BS-11: PDF attachment
2752
+ *
2753
+ * PDFs are SDK-engine-only in Stage 1 (no on-disk file to hand to the CLI/PTY
2754
+ * engine), so a CLI-engine session rejects them explicitly instead of silently
2755
+ * dropping them (AC 15).
2445
2756
  */
2446
- function validateImages(images, lang) {
2757
+ function validateAttachments(images, lang, engineMode) {
2447
2758
  const t = i18next.getFixedT(lang);
2448
2759
  if (images.length > IMAGE_CONSTRAINTS.MAX_COUNT) {
2449
2760
  return { valid: false, error: t('ws.error.maxImages', { value: IMAGE_CONSTRAINTS.MAX_COUNT }) };
2450
2761
  }
2762
+ let totalSizeBytes = 0;
2451
2763
  for (const img of images) {
2452
- if (!IMAGE_CONSTRAINTS.ACCEPTED_TYPES.includes(img.mimeType)) {
2764
+ const isPdf = PDF_CONSTRAINTS.ACCEPTED_TYPES.includes(img.mimeType);
2765
+ if (!isPdf && !IMAGE_CONSTRAINTS.ACCEPTED_TYPES.includes(img.mimeType)) {
2453
2766
  return { valid: false, error: t('ws.error.unsupportedImageFormat', { value: img.mimeType }) };
2454
2767
  }
2768
+ if (isPdf && engineMode === 'cli') {
2769
+ return { valid: false, error: t('ws.error.pdfRequiresSdkEngine') };
2770
+ }
2455
2771
  // base64 size approximation: base64 length * 0.75 ≈ original bytes
2456
2772
  const sizeBytes = Math.ceil(img.data.length * 0.75);
2457
- if (sizeBytes > IMAGE_CONSTRAINTS.MAX_SIZE_BYTES) {
2773
+ if (isPdf && sizeBytes > PDF_CONSTRAINTS.MAX_SIZE_BYTES) {
2774
+ return { valid: false, error: t('ws.error.pdfSizeExceeded', { value: img.name }) };
2775
+ }
2776
+ if (!isPdf && sizeBytes > IMAGE_CONSTRAINTS.MAX_SIZE_BYTES) {
2458
2777
  return { valid: false, error: t('ws.error.imageSizeExceeded', { value: img.name }) };
2459
2778
  }
2779
+ totalSizeBytes += sizeBytes;
2780
+ }
2781
+ // Combined raw-size cap — the Claude API caps the whole request at 32MB (AC 14)
2782
+ if (totalSizeBytes > ATTACHMENT_TOTAL_MAX_SIZE_BYTES) {
2783
+ return { valid: false, error: t('ws.error.attachmentsSizeExceeded') };
2460
2784
  }
2461
2785
  return { valid: true };
2462
2786
  }
@@ -2469,9 +2793,14 @@ async function handleChatSend(stream, data, abortController, lang) {
2469
2793
  const emit = createStreamEmit(stream);
2470
2794
  const t = i18next.getFixedT(lang);
2471
2795
  const { content: rawContent, workingDirectory, sessionId, resume, permissionMode, model, images, effort, resumeSessionAt: rawResumeSessionAt, forkSession, rewindToMessageUuid } = data;
2472
- // Validate images if present (Story 5.5)
2796
+ // Story 33.3 / BS-11: resolve the effective engine mode (override > global > 'sdk'
2797
+ // default) BEFORE attachment validation — PDF attachments are SDK-engine-only and a
2798
+ // CLI-engine session must reject them here, before the user:message broadcast below.
2799
+ // Resolved ONCE into this local; all later engine-mode decisions in this turn reuse it.
2800
+ const engineMode = await projectService.getEffectiveEngineMode(workingDirectory);
2801
+ // Validate attachments if present (Story 5.5, PDF: Story BS-11)
2473
2802
  if (images && images.length > 0) {
2474
- const validation = validateImages(images, lang);
2803
+ const validation = validateAttachments(images, lang, engineMode);
2475
2804
  if (!validation.valid) {
2476
2805
  emit('error', {
2477
2806
  code: ERROR_CODES.VALIDATION_ERROR,
@@ -2590,6 +2919,7 @@ async function handleChatSend(stream, data, abortController, lang) {
2590
2919
  if (existsSync(filePath)) {
2591
2920
  const rawMessages = await parseJSONLFile(filePath);
2592
2921
  const tree = buildRawMessageTree(rawMessages);
2922
+ repairPostCompactParenting(tree);
2593
2923
  // Find the branch path that contains the fork point message
2594
2924
  const selections = findBranchSelectionsForUuid(tree.roots, resumeSessionAt)
2595
2925
  ?? getDefaultRawBranchSelections(tree.roots);
@@ -2625,6 +2955,19 @@ async function handleChatSend(stream, data, abortController, lang) {
2625
2955
  // short-circuits while this is set so no activity can re-arm the timer, and
2626
2956
  // canUseTool clears it + re-arms once the user responds.
2627
2957
  let permissionWaiting = false;
2958
+ // Same pause while engine-announced background tasks are pending. The engine
2959
+ // holds the stream open and stays SILENT while a background agent works
2960
+ // (실측 2026-07-02: 78s of zero stream messages, then the notification) — the
2961
+ // inactivity timer would read that healthy quiet as a stall and abort the
2962
+ // turn at exactly chatTimeoutMs into every longer background task. The
2963
+ // engine's own task lifecycle (task_started → task_notification) is the
2964
+ // authoritative liveness signal here; a genuinely dead engine ends the
2965
+ // stream itself, which ends the turn without the timer's help.
2966
+ let backgroundTasksPending = false;
2967
+ // Set once the turn's stream loop has ended — resetTimeout becomes a no-op so
2968
+ // late tracker transitions (JSONL-poller phase) can't re-arm an abort bomb
2969
+ // into a finished turn.
2970
+ let timerDisposed = false;
2628
2971
  // Snapshot JSONL files before SDK query to detect phantom checkpoint files.
2629
2972
  // The SDK's file checkpointing creates separate JSONL files (new UUIDs) with
2630
2973
  // only file-history-snapshot entries alongside the real session file. These
@@ -2640,23 +2983,75 @@ async function handleChatSend(stream, data, abortController, lang) {
2640
2983
  catch {
2641
2984
  // Non-critical — cleanup will be skipped if snapshot fails
2642
2985
  }
2986
+ // Story 38.7: residency reconciliation flag — the finally keeps a resident engine
2987
+ // ONLY when the turn ended cleanly (success or a session-preserving user interrupt);
2988
+ // any failure / hard-abort / timeout leaves the engine untrustworthy, so it is evicted
2989
+ // there and the next send resumes fresh from JSONL.
2990
+ let turnSucceeded = false;
2643
2991
  try {
2644
2992
  // Load preferences early for advanced settings + timeout
2645
2993
  const effectivePrefs = await preferencesService.getEffectivePreferences();
2646
- // Story 33.3: resolve the effective engine mode (override > global > 'sdk' default).
2647
- // Captured once into a local so the progress-callback gating below reuses the same
2648
- // decision (no second resolve, and `engineMode === 'cli'` stays consistent).
2649
- const [engineMode, isBmadProject] = await Promise.all([
2650
- projectService.getEffectiveEngineMode(workingDirectory),
2651
- projectService.checkBmadProject(workingDirectory),
2652
- ]);
2994
+ // engineMode was resolved once before attachment validation (BS-11 hoist) the
2995
+ // progress-callback gating below reuses that same local (no second resolve, and
2996
+ // `engineMode === 'cli'` stays consistent).
2997
+ const isBmadProject = await projectService.checkBmadProject(workingDirectory);
2653
2998
  // Auto-compaction master switch (both engines). Default ON. Gates the engine-internal
2654
2999
  // auto-compact (forwarded into each engine's settings) AND Hammoc's overflow-triggered
2655
3000
  // /compact recovery below, so one toggle controls all auto-compaction.
2656
3001
  const autoCompactEnabled = effectivePrefs.autoCompactEnabled ?? true;
2657
- const chatService = createChatEngine(engineMode, { workingDirectory, permissionMode, cliBinaryPath: effectivePrefs.cliBinaryPath, cliShowThinkingSummaries: effectivePrefs.cliShowThinkingSummaries, cliResumeChoice: effectivePrefs.cliResumeChoice, autoCompactEnabled, planModeBypassBehavior: effectivePrefs.planModeBypassBehavior });
3002
+ // Story 38.7: keep the sync residency snapshot current from the prefs we just
3003
+ // read (so a settings change takes effect without re-instantiating the registry).
3004
+ refreshResidencyConfig(effectivePrefs);
3005
+ // Story 38.7 — reuse a live resident engine (skip the ~3s re-spawn) or create a
3006
+ // fresh one and register it as resident. `registry.get` returns a live engine
3007
+ // (and touches its activity) or drops a crashed one and returns undefined
3008
+ // (crash-reconnect → the create branch resumes from JSONL). Fork / branch
3009
+ // (resumeSessionAt) / rewind are query() CREATION options, so they never reuse.
3010
+ const isBranchOrRewind = !!resumeSessionAt || !!rewindToMessageUuid;
3011
+ const residentEngine = engineResidency.get(stream.sessionId);
3012
+ const reuseResident = shouldReuseResidentEngine({
3013
+ hasLiveResident: !!residentEngine,
3014
+ engineMode,
3015
+ isFork: !!forkSession,
3016
+ isBranchOrRewind,
3017
+ });
3018
+ let chatService;
3019
+ if (reuseResident) {
3020
+ chatService = residentEngine;
3021
+ // The reused live query keeps its CREATION-time parameters; apply this turn's
3022
+ // runtime-injectable params to it (Story 38.4 seam — first observable here).
3023
+ // model/permissionMode have live-query setters; other creation-time params
3024
+ // (effort, prompt/tool config) stay fixed for the resident engine's lifetime.
3025
+ try {
3026
+ await chatService.setModel?.(model);
3027
+ }
3028
+ catch (err) {
3029
+ log.warn(`setModel on reuse failed for ${stream.sessionId}: ${err instanceof Error ? err.message : String(err)}`);
3030
+ }
3031
+ if (permissionMode) {
3032
+ try {
3033
+ await chatService.setPermissionMode(permissionMode);
3034
+ }
3035
+ catch (err) {
3036
+ log.warn(`setPermissionMode on reuse failed for ${stream.sessionId}: ${err instanceof Error ? err.message : String(err)}`);
3037
+ }
3038
+ }
3039
+ log.info(`[RESIDENCY] reusing resident engine for session ${stream.sessionId} (no re-spawn)`);
3040
+ }
3041
+ else {
3042
+ chatService = createChatEngine(engineMode, { workingDirectory, permissionMode, cliBinaryPath: effectivePrefs.cliBinaryPath, cliShowThinkingSummaries: effectivePrefs.cliShowThinkingSummaries, cliResumeChoice: effectivePrefs.cliResumeChoice, autoCompactEnabled, planModeBypassBehavior: effectivePrefs.planModeBypassBehavior });
3043
+ }
2658
3044
  stream.chatService = chatService;
2659
3045
  stream.engineMode = engineMode;
3046
+ // Register a freshly-created SDK engine as resident NOW (before the send) so its
3047
+ // callback path skips the turn-end close() and the process survives into the next
3048
+ // turn. A fork's assigned id is unknown until re-key, so it is registered there
3049
+ // instead (see rekeyStream) — registering under the ORIGINAL key here would clobber
3050
+ // the original session's resident entry. A branch/rewind create REPLACES the prior
3051
+ // resident (register closes the old one). CLI engines are a feature-detect no-op.
3052
+ if (!reuseResident && shouldRegisterOnCreate({ engineMode, isFork: !!forkSession })) {
3053
+ await engineResidency.register(stream.sessionId, chatService, engineMode);
3054
+ }
2660
3055
  const effectiveEffort = clampEffortForModel(effort ?? effectivePrefs.defaultEffort, model);
2661
3056
  // Opus 4.7+ flipped `thinking.display` default to 'omitted' — ThinkingBlock UI stays blank
2662
3057
  // unless we explicitly opt in. For adaptive-thinking models, forward an explicit `thinking`
@@ -2701,6 +3096,14 @@ async function handleChatSend(stream, data, abortController, lang) {
2701
3096
  // Create canUseTool callback for permission & AskUserQuestion handling
2702
3097
  // Promise stays pending if socket disconnected — SDK naturally waits
2703
3098
  const canUseTool = async (toolName, input, options) => {
3099
+ // Hammoc forbids tool-level background execution so `end_turn = done` stays true —
3100
+ // see backgroundToolGuard. Must run before the bypass auto-approve branches below,
3101
+ // otherwise a background Bash would slip through in Bypass mode.
3102
+ const bgDeny = denyBackgroundBash(toolName, input);
3103
+ if (bgDeny) {
3104
+ log.info(`[canUseTool] blocking ${toolName} run_in_background — steer to OS-level detach`);
3105
+ return bgDeny;
3106
+ }
2704
3107
  // Auto-approve ExitPlanMode when the current permission mode is Bypass.
2705
3108
  // Use chatService.getPermissionMode() instead of the closure-captured variable
2706
3109
  // so that mid-stream permission mode changes (e.g. Plan → Bypass) are reflected.
@@ -2816,11 +3219,14 @@ async function handleChatSend(stream, data, abortController, lang) {
2816
3219
  const resetTimeout = (source) => {
2817
3220
  if (isCliEngine)
2818
3221
  return; // CLI mode: no inactivity timeout (see above)
3222
+ if (timerDisposed)
3223
+ return; // turn already ended — never re-arm
2819
3224
  // Story 35.1: while awaiting user input (permission / AskUserQuestion) the timer
2820
3225
  // is paused — ignore every activity-driven reset (onActivity, raw PTY frames,
2821
3226
  // compact/retry) so the wait stays indefinite. canUseTool clears the pause and
2822
3227
  // calls this once to re-arm when the user responds.
2823
- if (permissionWaiting)
3228
+ // Same for pending background tasks: the engine's silence is by-design there.
3229
+ if (permissionWaiting || backgroundTasksPending)
2824
3230
  return;
2825
3231
  if (source)
2826
3232
  lastResetSource = source;
@@ -2828,9 +3234,37 @@ async function handleChatSend(stream, data, abortController, lang) {
2828
3234
  clearTimeout(timeoutId);
2829
3235
  timeoutId = setTimeout(() => {
2830
3236
  log.warn(`TIMEOUT FIRED after ${timeoutMs}ms inactivity (last reset by: ${lastResetSource})`);
2831
- abortController.abort('timeout');
3237
+ // Story 38.7: on a resident SDK engine the per-turn abortController does not
3238
+ // reach the reused live query and processStream never polls it, so a hard abort
3239
+ // cannot stop a stuck turn-2+ generation — route the timeout through interrupt()
3240
+ // (the same primitive as Stop). Mark `timedOut` so the interrupt error result is
3241
+ // suppressed, a timeout error is surfaced, and the engine is evicted (fresh
3242
+ // resume next send). The CLI / not-yet-created path still hard-aborts (where the
3243
+ // abort IS wired and the catch below emits the timeout error).
3244
+ if (resolveStopStrategy({ engineMode, canInterrupt: !!stream.chatService?.interrupt }) === 'interrupt') {
3245
+ stream.timedOut = true;
3246
+ }
3247
+ requestTurnStop(stream, 'timeout');
2832
3248
  }, timeoutMs);
2833
3249
  };
3250
+ // Story 38.3 (AC4): dispose the inactivity timer at the TURN-ENDING result, not at
3251
+ // generator/finally teardown. Epic 38's persistent stream breaks the old
3252
+ // "generator lifetime = turn" assumption: after the turn-ending result the stream
3253
+ // stays open (background hold, follow-up), so dispose must key off the SAME result
3254
+ // boundary the engine's Stop hook decides. Called from the turn-ending onComplete
3255
+ // (below) — which fires only at the real end, never at an intermediate hold result —
3256
+ // so the timer is gone the instant the turn ends and the brief re-arm that
3257
+ // onBackgroundState(waiting=false) would otherwise trigger at that boundary is
3258
+ // cancelled. The finally still calls this as a crash/abort backstop. Idempotent.
3259
+ const disposeTimer = () => {
3260
+ if (timeoutId) {
3261
+ clearTimeout(timeoutId);
3262
+ timeoutId = null;
3263
+ }
3264
+ timerDisposed = true;
3265
+ };
3266
+ // Arm at turn start. (Per-turn arm across a resident process is Story 38.5; today
3267
+ // the engine is one turn per instance, so turn start == handleChatSend start.)
2834
3268
  resetTimeout('initial');
2835
3269
  // CLI screen-stall watchdog (soft, advisory — see cliScreenStallWatchdog). The reconstructed
2836
3270
  // screen changes ~1×/second while claude is healthy (spinner glyph + ticking clock), so a long
@@ -2890,13 +3324,15 @@ async function handleChatSend(stream, data, abortController, lang) {
2890
3324
  // Story 37.8: mirror refresh interval (ms) — the user's preference, applied as the engine's
2891
3325
  // trailing-throttle interval. undefined → engine default (200ms). Harmless when the mirror is off.
2892
3326
  const mirrorThrottleMs = effectivePrefs.cliMirrorThrottleMs;
2893
- const backgroundTracker = new BackgroundTaskTracker((state) => {
2894
- emit('background:waiting', {
2895
- sessionId: sessionIdRef.current ?? sessionId,
2896
- waiting: state.waiting,
2897
- pendingCount: state.pending,
2898
- });
2899
- });
3327
+ // The tracker is a thin pending-id set fed by SDK task events (task_started /
3328
+ // task-notification). Two consumers: (a) turn-boundary GATE 2 (2026-07-12 fix
3329
+ // chatService passes it into streamHandler, which holds the turn while a
3330
+ // completed task's queued notification has not been injected yet; the Stop
3331
+ // ledger alone cannot see that window), (b) the crash-safety-net poller
3332
+ // (waitForBackgroundTasks below). The amber "waiting" card follows the same
3333
+ // combined verdict via callbacks.onBackgroundState.
3334
+ const backgroundTracker = new BackgroundTaskTracker();
3335
+ stream.backgroundTracker = backgroundTracker;
2900
3336
  // Build shared callbacks (common logic for browser & queue paths)
2901
3337
  const { callbacks, sessionIdRef } = buildStreamCallbacks({
2902
3338
  emit,
@@ -2919,6 +3355,25 @@ async function handleChatSend(stream, data, abortController, lang) {
2919
3355
  sessionService.saveSessionId(workingDirectory, sid).catch(() => { });
2920
3356
  },
2921
3357
  });
3358
+ // Same object survives the wrapper reassignments below — the post-turn
3359
+ // background wait re-broadcasts follow-up records through it.
3360
+ stream.streamCallbacks = callbacks;
3361
+ // Story 38.3: a turn-only interrupt (Stop on the SDK engine) ends the turn with an
3362
+ // `error_during_execution` result. That is the intended stop, not a failure — swallow
3363
+ // its error emission (viewers already got `stream:detached`) so no spurious error card
3364
+ // appears; onComplete still finalizes the partial turn. Innermost wrapper so it survives
3365
+ // the resume-gating reassignments below (they capture and restore THIS function).
3366
+ const _preInterruptOnResultError = callbacks.onResultError;
3367
+ callbacks.onResultError = (data) => {
3368
+ // Story 38.7: an inactivity TIMEOUT on a resident engine also ends the turn via
3369
+ // interrupt() (its raw error result is likewise an intended stop) — suppress it and
3370
+ // surface a proper timeout error below instead.
3371
+ if (shouldSuppressResultError(!!stream.userInterrupted || !!stream.timedOut)) {
3372
+ log.info(`[interrupt] suppressing result:error for ${stream.timedOut ? 'timed-out' : 'user-interrupted'} turn (subtype=${data.subtype})`);
3373
+ return;
3374
+ }
3375
+ _preInterruptOnResultError?.(data);
3376
+ };
2922
3377
  // Track whether SDK has started producing user-visible output.
2923
3378
  // Used to guard resume-retry: if output was already emitted, retrying
2924
3379
  // would cause duplicate content / tool side-effects.
@@ -2932,11 +3387,23 @@ async function handleChatSend(stream, data, abortController, lang) {
2932
3387
  // Usage data is stored and included in stream:complete-messages.
2933
3388
  const _baseOnComplete = callbacks.onComplete;
2934
3389
  callbacks.onComplete = (response) => {
3390
+ // Story 38.3 (AC4): onComplete fires ONLY at the turn-ending result (an
3391
+ // intermediate background hold fires nothing) — so this is the exact turn
3392
+ // boundary at which to dispose the inactivity timer.
3393
+ disposeTimer();
3394
+ // Turn ended — restart the residency idle clock from NOW so the timeout is
3395
+ // measured from turn END, not turn start. The activity clock is otherwise only
3396
+ // set at turn start, so a turn longer than the idle timeout would leave the
3397
+ // engine instantly sweepable the moment it goes inactive. Pairs with the
3398
+ // in-progress guard above (which protects the turn WHILE running).
3399
+ engineResidency.touch(stream.sessionId);
2935
3400
  if (response.usage) {
2936
3401
  stream.deferredUsage = response.usage;
2937
3402
  emit('context:usage', response.usage);
2938
3403
  }
2939
- if (notificationService.shouldNotify(stream.sockets.size)) {
3404
+ // Story 38.3: a user-interrupted turn ends with a completion for finalization,
3405
+ // but it is a Stop, not a natural finish — skip the "완료" notification.
3406
+ if (shouldNotifyComplete(!!stream.userInterrupted, notificationService.shouldNotify(stream.sockets.size))) {
2940
3407
  notificationService.notifyComplete(stream.sessionId, response.content);
2941
3408
  }
2942
3409
  };
@@ -2944,6 +3411,37 @@ async function handleChatSend(stream, data, abortController, lang) {
2944
3411
  callbacks.onActivity = (messageType) => {
2945
3412
  resetTimeout(`onActivity:${messageType}`);
2946
3413
  };
3414
+ // Story 38.2: background-work state, derived from the engine's Stop-hook
3415
+ // snapshot (fired by processStream at each result). Replaces the retired
3416
+ // ack-marker tracker's onChange — same two consumers, same wire payload:
3417
+ // (a) inactivity-timer pause/resume (pending → stop the timer; back to 0 →
3418
+ // re-arm so normal hang detection governs the rest of the turn),
3419
+ // (b) the `background:waiting` emit (amber card).
3420
+ // Granularity note (AC6): the tracker onChange fired per tool_result; this
3421
+ // fires at the result/Stop boundary. The observable end-state is identical
3422
+ // (in-flight → card on, none → card off); mid-turn incremental granularity
3423
+ // is Story 38.3's concern, not restored here.
3424
+ callbacks.onBackgroundState = (state) => {
3425
+ const pending = state.pendingCount > 0;
3426
+ if (pending !== backgroundTasksPending) {
3427
+ backgroundTasksPending = pending;
3428
+ if (pending) {
3429
+ if (timeoutId) {
3430
+ clearTimeout(timeoutId);
3431
+ timeoutId = null;
3432
+ }
3433
+ log.info(`inactivity timer paused: ${state.pendingCount} background task(s) in flight`);
3434
+ }
3435
+ else {
3436
+ resetTimeout('background-tasks-resolved');
3437
+ }
3438
+ }
3439
+ emit('background:waiting', {
3440
+ sessionId: sessionIdRef.current ?? sessionId,
3441
+ waiting: state.waiting,
3442
+ pendingCount: state.pendingCount,
3443
+ });
3444
+ };
2947
3445
  callbacks.onError = (error) => {
2948
3446
  // Ignore abort errors from replaced streams (another-client or user-abort)
2949
3447
  if (abortController.signal.aborted) {
@@ -3023,15 +3521,59 @@ async function handleChatSend(stream, data, abortController, lang) {
3023
3521
  }
3024
3522
  }).catch(() => { });
3025
3523
  }
3524
+ // Guard (mirrors the queue executor's `!response.done` check): the engine can
3525
+ // end the stream WITHOUT ever yielding a result — silent process death. No
3526
+ // throw, no error result, so without this the turn would read as a clean
3527
+ // success and a dead engine would stay RESIDENT: every following send reuses
3528
+ // it and ends as another silent empty turn, each reuse refreshing the idle
3529
+ // clock so the sweep never reclaims it. A result-less end is also the NORMAL
3530
+ // shape for a replaced/stopped turn (close() unwinds the parked stream), so
3531
+ // an intended teardown returns quietly instead of raising an error card.
3532
+ // Returns true when the turn must FAIL (caller returns false → the finally's
3533
+ // residency reconciliation evicts the engine; next send resumes from JSONL).
3534
+ const failIfEndedWithoutResult = (r) => {
3535
+ if (r.done)
3536
+ return false;
3537
+ gatedResultError = null;
3538
+ gatedComplete = null;
3539
+ ungateCallbacks();
3540
+ if (!abortController.signal.aborted && !stream.userInterrupted) {
3541
+ log.error(`SDK stream ended without a result message: sessionId=${stream.sessionId}`);
3542
+ const msg = t('ws.error.engineTerminated');
3543
+ emit('error', { code: ERROR_CODES.CHAT_ERROR, message: msg });
3544
+ if (notificationService.shouldNotify(stream.sockets.size)) {
3545
+ notificationService.notifyError(stream.sessionId, msg);
3546
+ }
3547
+ }
3548
+ return true;
3549
+ };
3026
3550
  try {
3027
3551
  const sendResult = await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
3028
3552
  resetTimeout(`raw:${messageType}`);
3029
3553
  }, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
3030
3554
  emit('permission:mode-change', { mode });
3031
3555
  });
3556
+ // Story 38.7: the inactivity timeout ended this resident turn via interrupt() (its
3557
+ // raw error result was suppressed above). Surface the timeout error and FAIL the
3558
+ // turn — before the resume-retry check, so it isn't mistaken for a resume failure —
3559
+ // so the queue retries and the reconciliation evicts the (timed-out) engine for a
3560
+ // fresh resume next send.
3561
+ if (stream.timedOut) {
3562
+ gatedResultError = null;
3563
+ gatedComplete = null;
3564
+ ungateCallbacks();
3565
+ emit('error', { code: ERROR_CODES.TIMEOUT_ERROR, message: t('ws.error.timeout') });
3566
+ return false;
3567
+ }
3568
+ // Silent termination — the stream ended with no result at all (see helper).
3569
+ if (failIfEndedWithoutResult(sendResult)) {
3570
+ return false;
3571
+ }
3032
3572
  // SDK may return "No conversation found" as an error result (not a thrown exception).
3033
3573
  // Convert to a thrown error so the retry logic below can handle it.
3034
- if (sendResult.isError && isResumeAttempt && !abortController.signal.aborted && !hasEmittedOutput) {
3574
+ // Story 38.3: a user interrupt also ends the turn with an error result — it must
3575
+ // NOT be mistaken for a resume failure and retried (the user asked to stop).
3576
+ if (shouldRetryResumeAfterError({ isError: !!sendResult.isError, isResumeAttempt, aborted: abortController.signal.aborted, userInterrupted: !!stream.userInterrupted, hasEmittedOutput })) {
3035
3577
  log.info(`[RESUME-RETRY] SDK returned error result while resuming, converting to thrown error for retry. result="${(sendResult.content || '').slice(0, 200)}"`);
3036
3578
  throw new Error(sendResult.content || 'Resume returned error result');
3037
3579
  }
@@ -3061,21 +3603,37 @@ async function handleChatSend(stream, data, abortController, lang) {
3061
3603
  gatedResultError = null;
3062
3604
  gatedComplete = null;
3063
3605
  ungateCallbacks();
3606
+ // Story 38.7: on a resident engine the failed send's persistent query is NOT
3607
+ // closed at turn end (resident), so the /compact + retry would reuse the dead
3608
+ // query. Close it so /compact rebuilds a fresh resuming query (pre-residency this
3609
+ // reset came free from the turn-end close). No-op for a fresh/CLI engine.
3610
+ await chatService.close?.();
3064
3611
  resetTimeout('auto-compact');
3065
- // Run /compact to shrink context (preserves session file)
3066
- await chatService.sendMessageWithCallbacks('/compact', callbacks, chatOptions, canUseTool, (messageType) => {
3612
+ // Run /compact to shrink context (preserves session file). The command is
3613
+ // a maintenance turn — don't re-attach this send's user payload (SDK base64
3614
+ // images / CLI image paths) or re-apply the inline rewind to it; those
3615
+ // belong to the retried original message, not to /compact (images would
3616
+ // otherwise enter the context twice).
3617
+ const compactOptions = { ...chatOptions, images: undefined, attachedImagePaths: undefined, rewindToMessageUuid: undefined };
3618
+ const compactResult = await chatService.sendMessageWithCallbacks('/compact', callbacks, compactOptions, canUseTool, (messageType) => {
3067
3619
  resetTimeout(`compact:${messageType}`);
3068
3620
  }, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
3069
3621
  emit('permission:mode-change', { mode });
3070
3622
  });
3623
+ if (failIfEndedWithoutResult(compactResult)) {
3624
+ return false;
3625
+ }
3071
3626
  // Retry the original message after compaction
3072
3627
  log.info(`[AUTO-COMPACT] compaction done, retrying original message: sessionId=${sessionId}`);
3073
3628
  resetTimeout('auto-compact-retry');
3074
- await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
3629
+ const compactRetryResult = await chatService.sendMessageWithCallbacks(content, callbacks, chatOptions, canUseTool, (messageType) => {
3075
3630
  resetTimeout(`retry:${messageType}`);
3076
3631
  }, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
3077
3632
  emit('permission:mode-change', { mode });
3078
3633
  });
3634
+ if (failIfEndedWithoutResult(compactRetryResult)) {
3635
+ return false;
3636
+ }
3079
3637
  // Resume failed for other unknown reasons — retry without resume (fresh session).
3080
3638
  // Guards:
3081
3639
  // 1. Only when resuming (not a fresh session send)
@@ -3087,12 +3645,22 @@ async function handleChatSend(stream, data, abortController, lang) {
3087
3645
  && !abortController.signal.aborted
3088
3646
  && !hasEmittedOutput
3089
3647
  && parsedError.code === SDKErrorCode.UNKNOWN
3090
- && !chatOptions.resumeSessionAt) {
3648
+ && !chatOptions.resumeSessionAt
3649
+ // Data-loss guard: the retry below DELETES the session file. Only enter
3650
+ // for the conversation-less stub this recovery was built for — a file
3651
+ // with real history means a genuine resume failure (dead resident
3652
+ // engine, transient spawn error); surface that error instead of wiping
3653
+ // the user's conversation record.
3654
+ && !(sessionId && sessionFileHasConversation(sessionService.getSessionFilePath(projectSlug, sessionId)))) {
3091
3655
  log.info(`[RESUME-RETRY] resume failed, retrying without resume: sessionId=${sessionId}, error=${parsedError.message.slice(0, 120)}`);
3092
3656
  // Discard gated events and restore original callbacks for the retry
3093
3657
  gatedResultError = null;
3094
3658
  gatedComplete = null;
3095
3659
  ungateCallbacks();
3660
+ // Story 38.7: reset a resident engine's dead persistent query (kept open because
3661
+ // resident) so the retry rebuilds a fresh query with retryOptions (resume removed)
3662
+ // instead of reusing the failed one. No-op for a fresh/CLI engine.
3663
+ await chatService.close?.();
3096
3664
  // Delete stale session file from the aborted first send so the SDK
3097
3665
  // can create a fresh session with the same ID (avoids "Session ID already in use").
3098
3666
  if (sessionId) {
@@ -3111,11 +3679,14 @@ async function handleChatSend(stream, data, abortController, lang) {
3111
3679
  delete retryOptions.resume;
3112
3680
  delete retryOptions.resumeSessionAt;
3113
3681
  resetTimeout('resume-retry');
3114
- await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
3682
+ const retryResult = await chatService.sendMessageWithCallbacks(content, callbacks, retryOptions, canUseTool, (messageType) => {
3115
3683
  resetTimeout(`raw:${messageType}`);
3116
3684
  }, generationProgressCb, cliPhaseCb, screenFrameCb, mirrorThrottleMs, backgroundTracker, (mode) => {
3117
3685
  emit('permission:mode-change', { mode });
3118
3686
  });
3687
+ if (failIfEndedWithoutResult(retryResult)) {
3688
+ return false;
3689
+ }
3119
3690
  }
3120
3691
  else {
3121
3692
  // Not retrying — flush gated error events before re-throwing
@@ -3127,6 +3698,7 @@ async function handleChatSend(stream, data, abortController, lang) {
3127
3698
  throw sendError;
3128
3699
  }
3129
3700
  }
3701
+ turnSucceeded = true; // Story 38.7: clean end → keep the engine resident (see finally)
3130
3702
  return true;
3131
3703
  }
3132
3704
  catch (error) {
@@ -3156,9 +3728,17 @@ async function handleChatSend(stream, data, abortController, lang) {
3156
3728
  return false;
3157
3729
  }
3158
3730
  finally {
3731
+ // Backstop dispose: the turn-ending onComplete already disposed the timer at the
3732
+ // result boundary (Story 38.3 AC4, via the block-scoped disposeTimer); this inline
3733
+ // copy — using the function-scoped locals visible here — covers the paths that never
3734
+ // reach a turn-ending result: a thrown abort/error, or the generator ending mid-hold.
3735
+ // Late background-tracker transitions (JSONL-poller phase after this return) must not
3736
+ // re-arm the timer into a finished turn.
3159
3737
  if (timeoutId) {
3160
3738
  clearTimeout(timeoutId);
3739
+ timeoutId = null;
3161
3740
  }
3741
+ timerDisposed = true;
3162
3742
  // Screen-stall watchdog teardown: clear the pending timer so a stale fire can't leak into the
3163
3743
  // NEXT turn (the stream object is reused and its status flips back to 'running'), and drop any
3164
3744
  // active stall so every viewer's "looks stuck?" affordance clears when the turn ends.
@@ -3197,6 +3777,28 @@ async function handleChatSend(stream, data, abortController, lang) {
3197
3777
  log.warn('Failed to clean up phantom checkpoint files:', cleanupErr);
3198
3778
  }
3199
3779
  }
3780
+ // Story 38.7: residency reconciliation. Keep the engine resident only on a clean end
3781
+ // (success or a session-preserving interrupt → turnSucceeded). Any failure / hard
3782
+ // abort / timeout leaves the persistent query in a state isLive() can't flag (it stays
3783
+ // non-null after an error/abort — only close() nulls it), so evict + close it here;
3784
+ // the next send then misses the registry and resumes fresh from JSONL. The peek
3785
+ // identity guard avoids evicting a REPLACEMENT engine that an another-client send
3786
+ // already registered under this same session key.
3787
+ // A tracker-hold grace EXPIRY also evicts even though the turn SUCCEEDED
3788
+ // client-side (2026-07-12 dual-gate fix): the expiry abandoned a read parked
3789
+ // on the shared session iterator — kept resident, that dead read would steal
3790
+ // the next turn's first message, and a late follow-up could generate
3791
+ // invisibly. Eviction closes the query (settling the parked read); the next
3792
+ // send resumes from JSONL, where the already-recorded notification surfaces.
3793
+ const holdExpired = !!stream.chatService?.lastTurnTrackerHoldExpired;
3794
+ if ((!turnSucceeded || holdExpired) && stream.chatService && stream.engineMode === 'sdk') {
3795
+ if (engineResidency.peek(stream.sessionId) === stream.chatService) {
3796
+ if (holdExpired && turnSucceeded) {
3797
+ log.info(`[RESIDENCY] evicting after tracker-hold grace expiry: sessionId=${stream.sessionId} (parked read on the shared iterator must not survive into the next turn)`);
3798
+ }
3799
+ void engineResidency.remove(stream.sessionId);
3800
+ }
3801
+ }
3200
3802
  }
3201
3803
  }
3202
3804
  //# sourceMappingURL=websocket.js.map