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.
- package/README.md +9 -6
- package/package.json +1 -1
- package/packages/client/dist/assets/MermaidViewer-B-LwclIl.js +7 -0
- package/packages/client/dist/assets/PdfViewer-C3Nymwn3.js +12 -0
- package/packages/client/dist/assets/PdfViewer-QCQEGvBO.css +1 -0
- package/packages/client/dist/assets/abnfDiagram-VRR7QNED-GfhY_hnV.js +1 -0
- package/packages/client/dist/assets/{agentExampleHighlight-Dws80em9.js → agentExampleHighlight-CyhVLy0_.js} +1 -1
- package/packages/client/dist/assets/arc-8nyjXGC3.js +1 -0
- package/packages/client/dist/assets/architectureDiagram-ZJ3FMSHR-B4onXLm4.js +36 -0
- package/packages/client/dist/assets/blockDiagram-677ZJIJ3-D0D6ncaN.js +132 -0
- package/packages/client/dist/assets/c4Diagram-LMCZKHZV-CE7Tip-_.js +10 -0
- package/packages/client/dist/assets/channel-CPjlbdOo.js +1 -0
- package/packages/client/dist/assets/chunk-2Q5K7J3B-4rW1da1Z.js +1 -0
- package/packages/client/dist/assets/chunk-32BRIVSS-JuzxS4QA.js +1 -0
- package/packages/client/dist/assets/chunk-5VM5RSS4-WRNf-xsi.js +15 -0
- package/packages/client/dist/assets/chunk-EX3LRPZG-BDWPlBKO.js +231 -0
- package/packages/client/dist/assets/chunk-JWPE2WC7-B8nCyQAc.js +1 -0
- package/packages/client/dist/assets/chunk-MOJQB5TN-7c-yriHe.js +88 -0
- package/packages/client/dist/assets/chunk-RYQCIY6F-LTDywRs7.js +1 -0
- package/packages/client/dist/assets/chunk-V7JOEXUC-DjVgQpjl.js +206 -0
- package/packages/client/dist/assets/chunk-VR4S4FIN-7jDdkUpx.js +1 -0
- package/packages/client/dist/assets/chunk-XXDRQBXY-ENKV2O8a.js +1 -0
- package/packages/client/dist/assets/classDiagram-OUVF2IWQ-C0PbC7Pi.js +1 -0
- package/packages/client/dist/assets/classDiagram-v2-EOCWNBFH-C0PbC7Pi.js +1 -0
- package/packages/client/dist/assets/{commandTokenHighlight-aR071CNy.js → commandTokenHighlight-DGP0RKx0.js} +1 -1
- package/packages/client/dist/assets/cose-bilkent-JH36ORCC-CQTNdQ-g.js +1 -0
- package/packages/client/dist/assets/cynefin-VYW2F7L2-BYlAJRcO.js +178 -0
- package/packages/client/dist/assets/cynefinDiagram-TSTJHNR4-7zyUTflT.js +62 -0
- package/packages/client/dist/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/packages/client/dist/assets/dagre-VKFMJZFB-DyZir42W.js +4 -0
- package/packages/client/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/packages/client/dist/assets/diagram-FQU43EPY-CmNAU8Nw.js +3 -0
- package/packages/client/dist/assets/diagram-G47NLZAW-B0EIk03o.js +24 -0
- package/packages/client/dist/assets/diagram-NH7WQ7WH-Dulv0f1N.js +24 -0
- package/packages/client/dist/assets/diagram-OA4YK3LP-DEEcMLgi.js +30 -0
- package/packages/client/dist/assets/diagram-WEI45ONY-ZYebAZ93.js +41 -0
- package/packages/client/dist/assets/ebnfDiagram-CCIWWBDH-DCHpt3qn.js +1 -0
- package/packages/client/dist/assets/erDiagram-Q63AITRT-B6T87-48.js +85 -0
- package/packages/client/dist/assets/flowDiagram-23GEKE2U-DAcW6mKJ.js +156 -0
- package/packages/client/dist/assets/ganttDiagram-NO4QXBWP-Ct_eFFcM.js +292 -0
- package/packages/client/dist/assets/gitGraphDiagram-IHSO6WYX-BGi8Fdm2.js +106 -0
- package/packages/client/dist/assets/graph-C9eacEi8.js +1 -0
- package/packages/client/dist/assets/index-CtMiUbMJ.js +1671 -0
- package/packages/client/dist/assets/index-hTu6ZG2N.js +2 -0
- package/packages/client/dist/assets/index-oBa2uNlv.css +32 -0
- package/packages/client/dist/assets/infoDiagram-FWYZ7A6U-CqvwbXJn.js +2 -0
- package/packages/client/dist/assets/init-Gi6I4Gst.js +1 -0
- package/packages/client/dist/assets/ishikawaDiagram-FXEZZL3T-CHhr5ioQ.js +70 -0
- package/packages/client/dist/assets/journeyDiagram-5HDEW3XC-d20NzZAd.js +139 -0
- package/packages/client/dist/assets/kanban-definition-HUTT4EX6-CHx3wOer.js +89 -0
- package/packages/client/dist/assets/katex-C5jXJg4s.js +257 -0
- package/packages/client/dist/assets/layout-DEXfKzaS.js +1 -0
- package/packages/client/dist/assets/linear-Cnp1E9M4.js +1 -0
- package/packages/client/dist/assets/map-Czzmt4hB.js +1 -0
- package/packages/client/dist/assets/mermaid.core-jAhLhPYr.js +314 -0
- package/packages/client/dist/assets/mindmap-definition-LN4V7U3C-zi30RPoJ.js +96 -0
- package/packages/client/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/packages/client/dist/assets/pdf.worker.min-qwK7q_zL.mjs +28 -0
- package/packages/client/dist/assets/pegDiagram-2B236MQR-4gKLzTqu.js +1 -0
- package/packages/client/dist/assets/pieDiagram-ENE6RG2P-DRDhpjxP.js +39 -0
- package/packages/client/dist/assets/quadrantDiagram-ABIIQ3AL-FmTxRIlW.js +7 -0
- package/packages/client/dist/assets/railroadDiagram-RFXS5EU6-Bc-Q7iPs.js +1 -0
- package/packages/client/dist/assets/requirementDiagram-TGXJPOKE-D209tlqv.js +84 -0
- package/packages/client/dist/assets/sankeyDiagram-HTMAVEWB--I_lSbCV.js +40 -0
- package/packages/client/dist/assets/sequenceDiagram-DBY2YBRQ-Da5YJcDJ.js +162 -0
- package/packages/client/dist/assets/sizeCapture-X5ZJPWSS-DYuc-rzB.js +1 -0
- package/packages/client/dist/assets/{snippetTokenHighlight-ot6u3hW4.js → snippetTokenHighlight-qXHxvlCE.js} +1 -1
- package/packages/client/dist/assets/stateDiagram-2N3HPSRC-DoOeZoRP.js +1 -0
- package/packages/client/dist/assets/stateDiagram-v2-6OUMAXLB-DLkUyWs6.js +1 -0
- package/packages/client/dist/assets/swimlanes-5IMT3BWC-BcR7uCTf.js +2 -0
- package/packages/client/dist/assets/swimlanesDiagram-G3AALYLV-C0FDo-hf.js +8 -0
- package/packages/client/dist/assets/timeline-definition-FHXFAJF6-CLzKR6_i.js +120 -0
- package/packages/client/dist/assets/vennDiagram-L72KCM5P-TajEacPR.js +34 -0
- package/packages/client/dist/assets/wardleyDiagram-EHGQE667-DKvVEb3V.js +78 -0
- package/packages/client/dist/assets/xychartDiagram-FW5EYKEG-CaJG8r0j.js +7 -0
- package/packages/client/dist/index.html +2 -2
- package/packages/client/dist/sw.js +2 -2
- package/packages/server/dist/controllers/accountController.d.ts.map +1 -1
- package/packages/server/dist/controllers/accountController.js +31 -11
- package/packages/server/dist/controllers/accountController.js.map +1 -1
- package/packages/server/dist/controllers/fileSystemController.d.ts +6 -0
- package/packages/server/dist/controllers/fileSystemController.d.ts.map +1 -1
- package/packages/server/dist/controllers/fileSystemController.js +34 -0
- package/packages/server/dist/controllers/fileSystemController.js.map +1 -1
- package/packages/server/dist/controllers/projectController.d.ts.map +1 -1
- package/packages/server/dist/controllers/projectController.js +4 -2
- package/packages/server/dist/controllers/projectController.js.map +1 -1
- package/packages/server/dist/controllers/sessionController.d.ts +5 -0
- package/packages/server/dist/controllers/sessionController.d.ts.map +1 -1
- package/packages/server/dist/controllers/sessionController.js +95 -3
- package/packages/server/dist/controllers/sessionController.js.map +1 -1
- package/packages/server/dist/handlers/hookBridge.d.ts +131 -0
- package/packages/server/dist/handlers/hookBridge.d.ts.map +1 -0
- package/packages/server/dist/handlers/hookBridge.js +178 -0
- package/packages/server/dist/handlers/hookBridge.js.map +1 -0
- package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
- package/packages/server/dist/handlers/streamCallbacks.js +30 -4
- package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
- package/packages/server/dist/handlers/websocket.d.ts +44 -2
- package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
- package/packages/server/dist/handlers/websocket.js +666 -64
- package/packages/server/dist/handlers/websocket.js.map +1 -1
- package/packages/server/dist/index.js +4 -1
- package/packages/server/dist/index.js.map +1 -1
- package/packages/server/dist/locales/en/server.json +9 -3
- package/packages/server/dist/locales/es/server.json +9 -3
- package/packages/server/dist/locales/ja/server.json +9 -3
- package/packages/server/dist/locales/ko/server.json +9 -3
- package/packages/server/dist/locales/pt/server.json +9 -3
- package/packages/server/dist/locales/zh-CN/server.json +9 -3
- package/packages/server/dist/routes/account.d.ts.map +1 -1
- package/packages/server/dist/routes/account.js +4 -2
- package/packages/server/dist/routes/account.js.map +1 -1
- package/packages/server/dist/routes/fileSystem.d.ts.map +1 -1
- package/packages/server/dist/routes/fileSystem.js +2 -0
- package/packages/server/dist/routes/fileSystem.js.map +1 -1
- package/packages/server/dist/routes/sessions.d.ts.map +1 -1
- package/packages/server/dist/routes/sessions.js +2 -0
- package/packages/server/dist/routes/sessions.js.map +1 -1
- package/packages/server/dist/services/accountInfoService.d.ts +36 -1
- package/packages/server/dist/services/accountInfoService.d.ts.map +1 -1
- package/packages/server/dist/services/accountInfoService.js +90 -4
- package/packages/server/dist/services/accountInfoService.js.map +1 -1
- package/packages/server/dist/services/accountStorageService.d.ts +55 -9
- package/packages/server/dist/services/accountStorageService.d.ts.map +1 -1
- package/packages/server/dist/services/accountStorageService.js +244 -12
- package/packages/server/dist/services/accountStorageService.js.map +1 -1
- package/packages/server/dist/services/chatEngine.d.ts +59 -3
- package/packages/server/dist/services/chatEngine.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.d.ts +112 -3
- package/packages/server/dist/services/chatService.d.ts.map +1 -1
- package/packages/server/dist/services/chatService.js +353 -98
- package/packages/server/dist/services/chatService.js.map +1 -1
- package/packages/server/dist/services/claudeLoginService.d.ts +14 -3
- package/packages/server/dist/services/claudeLoginService.d.ts.map +1 -1
- package/packages/server/dist/services/claudeLoginService.js +18 -4
- package/packages/server/dist/services/claudeLoginService.js.map +1 -1
- package/packages/server/dist/services/cliChatEngine.d.ts.map +1 -1
- package/packages/server/dist/services/cliChatEngine.js +124 -22
- package/packages/server/dist/services/cliChatEngine.js.map +1 -1
- package/packages/server/dist/services/cliModalDetect.d.ts +14 -0
- package/packages/server/dist/services/cliModalDetect.d.ts.map +1 -1
- package/packages/server/dist/services/cliModalDetect.js +12 -3
- package/packages/server/dist/services/cliModalDetect.js.map +1 -1
- package/packages/server/dist/services/cliSessionPool.js +1 -1
- package/packages/server/dist/services/cliSessionPool.js.map +1 -1
- package/packages/server/dist/services/commandService.d.ts.map +1 -1
- package/packages/server/dist/services/commandService.js +19 -9
- package/packages/server/dist/services/commandService.js.map +1 -1
- package/packages/server/dist/services/engineResidencyRegistry.d.ts +112 -0
- package/packages/server/dist/services/engineResidencyRegistry.d.ts.map +1 -0
- package/packages/server/dist/services/engineResidencyRegistry.js +195 -0
- package/packages/server/dist/services/engineResidencyRegistry.js.map +1 -0
- package/packages/server/dist/services/harnessAgentService.d.ts.map +1 -1
- package/packages/server/dist/services/harnessAgentService.js +12 -2
- package/packages/server/dist/services/harnessAgentService.js.map +1 -1
- package/packages/server/dist/services/harnessLintService.d.ts.map +1 -1
- package/packages/server/dist/services/harnessLintService.js +25 -114
- package/packages/server/dist/services/harnessLintService.js.map +1 -1
- package/packages/server/dist/services/historyParser.d.ts.map +1 -1
- package/packages/server/dist/services/historyParser.js +23 -6
- package/packages/server/dist/services/historyParser.js.map +1 -1
- package/packages/server/dist/services/imageStorageService.d.ts.map +1 -1
- package/packages/server/dist/services/imageStorageService.js +7 -0
- package/packages/server/dist/services/imageStorageService.js.map +1 -1
- package/packages/server/dist/services/projectService.d.ts +4 -1
- package/packages/server/dist/services/projectService.d.ts.map +1 -1
- package/packages/server/dist/services/projectService.js +14 -1
- package/packages/server/dist/services/projectService.js.map +1 -1
- package/packages/server/dist/services/promptQueue.d.ts +34 -0
- package/packages/server/dist/services/promptQueue.d.ts.map +1 -0
- package/packages/server/dist/services/promptQueue.js +80 -0
- package/packages/server/dist/services/promptQueue.js.map +1 -0
- package/packages/server/dist/services/ptyService.d.ts.map +1 -1
- package/packages/server/dist/services/ptyService.js +4 -1
- package/packages/server/dist/services/ptyService.js.map +1 -1
- package/packages/server/dist/services/queueService.d.ts.map +1 -1
- package/packages/server/dist/services/queueService.js +62 -4
- package/packages/server/dist/services/queueService.js.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.d.ts +73 -3
- package/packages/server/dist/services/rateLimitProbeService.d.ts.map +1 -1
- package/packages/server/dist/services/rateLimitProbeService.js +397 -30
- package/packages/server/dist/services/rateLimitProbeService.js.map +1 -1
- package/packages/server/dist/services/sessionBufferManager.d.ts.map +1 -1
- package/packages/server/dist/services/sessionBufferManager.js +22 -4
- package/packages/server/dist/services/sessionBufferManager.js.map +1 -1
- package/packages/server/dist/services/sessionExportService.d.ts +34 -0
- package/packages/server/dist/services/sessionExportService.d.ts.map +1 -0
- package/packages/server/dist/services/sessionExportService.js +269 -0
- package/packages/server/dist/services/sessionExportService.js.map +1 -0
- package/packages/server/dist/services/sessionService.d.ts +6 -0
- package/packages/server/dist/services/sessionService.d.ts.map +1 -1
- package/packages/server/dist/services/sessionService.js +30 -9
- package/packages/server/dist/services/sessionService.js.map +1 -1
- package/packages/server/dist/services/streamHandler.d.ts +177 -1
- package/packages/server/dist/services/streamHandler.d.ts.map +1 -1
- package/packages/server/dist/services/streamHandler.js +425 -39
- package/packages/server/dist/services/streamHandler.js.map +1 -1
- package/packages/server/dist/services/workspaceContext.d.ts.map +1 -1
- package/packages/server/dist/services/workspaceContext.js +6 -0
- package/packages/server/dist/services/workspaceContext.js.map +1 -1
- package/packages/server/dist/snippets/fix-lint-command-not-on-path +13 -0
- package/packages/server/dist/snippets/fix-lint-duplicate-name +12 -0
- package/packages/server/dist/snippets/fix-lint-env-var-undefined +13 -0
- package/packages/server/dist/snippets/fix-lint-regex-invalid +14 -0
- package/packages/server/dist/snippets/fix-lint-tools-non-standard +14 -0
- package/packages/server/dist/snippets/fix-lint-url-invalid +13 -0
- package/packages/server/dist/snippets/fix-lint-yaml-parse-error +14 -0
- package/packages/server/dist/snippets/fix-malformed-skill +17 -0
- package/packages/server/dist/utils/backgroundHookSettings.d.ts +31 -0
- package/packages/server/dist/utils/backgroundHookSettings.d.ts.map +1 -0
- package/packages/server/dist/utils/backgroundHookSettings.js +41 -0
- package/packages/server/dist/utils/backgroundHookSettings.js.map +1 -0
- package/packages/server/dist/utils/backgroundTaskPoller.d.ts +69 -0
- package/packages/server/dist/utils/backgroundTaskPoller.d.ts.map +1 -0
- package/packages/server/dist/utils/backgroundTaskPoller.js +356 -0
- package/packages/server/dist/utils/backgroundTaskPoller.js.map +1 -0
- package/packages/server/dist/utils/backgroundTaskTracker.d.ts +51 -3
- package/packages/server/dist/utils/backgroundTaskTracker.d.ts.map +1 -1
- package/packages/server/dist/utils/backgroundTaskTracker.js +117 -9
- package/packages/server/dist/utils/backgroundTaskTracker.js.map +1 -1
- package/packages/server/dist/utils/backgroundToolGuard.d.ts +26 -0
- package/packages/server/dist/utils/backgroundToolGuard.d.ts.map +1 -0
- package/packages/server/dist/utils/backgroundToolGuard.js +40 -0
- package/packages/server/dist/utils/backgroundToolGuard.js.map +1 -0
- package/packages/server/dist/utils/boundedRing.d.ts +44 -0
- package/packages/server/dist/utils/boundedRing.d.ts.map +1 -0
- package/packages/server/dist/utils/boundedRing.js +77 -0
- package/packages/server/dist/utils/boundedRing.js.map +1 -0
- package/packages/server/dist/utils/effortUtils.d.ts +7 -6
- package/packages/server/dist/utils/effortUtils.d.ts.map +1 -1
- package/packages/server/dist/utils/effortUtils.js +10 -9
- package/packages/server/dist/utils/effortUtils.js.map +1 -1
- package/packages/server/dist/utils/messageTree.d.ts +25 -0
- package/packages/server/dist/utils/messageTree.d.ts.map +1 -1
- package/packages/server/dist/utils/messageTree.js +167 -3
- package/packages/server/dist/utils/messageTree.js.map +1 -1
- package/packages/server/dist/utils/pathUtils.d.ts.map +1 -1
- package/packages/server/dist/utils/pathUtils.js +1 -0
- package/packages/server/dist/utils/pathUtils.js.map +1 -1
- package/packages/server/dist/utils/residencyActivation.d.ts +61 -0
- package/packages/server/dist/utils/residencyActivation.d.ts.map +1 -0
- package/packages/server/dist/utils/residencyActivation.js +60 -0
- package/packages/server/dist/utils/residencyActivation.js.map +1 -0
- package/packages/server/dist/utils/residencyPolicy.d.ts +62 -0
- package/packages/server/dist/utils/residencyPolicy.d.ts.map +1 -0
- package/packages/server/dist/utils/residencyPolicy.js +82 -0
- package/packages/server/dist/utils/residencyPolicy.js.map +1 -0
- package/packages/server/dist/utils/runtimeParamStrategy.d.ts +51 -0
- package/packages/server/dist/utils/runtimeParamStrategy.d.ts.map +1 -0
- package/packages/server/dist/utils/runtimeParamStrategy.js +60 -0
- package/packages/server/dist/utils/runtimeParamStrategy.js.map +1 -0
- package/packages/server/dist/utils/stopStrategy.d.ts +62 -0
- package/packages/server/dist/utils/stopStrategy.d.ts.map +1 -0
- package/packages/server/dist/utils/stopStrategy.js +65 -0
- package/packages/server/dist/utils/stopStrategy.js.map +1 -0
- package/packages/server/resources/internals/INDEX.md +1 -1
- package/packages/server/resources/internals/image-storage.md +14 -2
- package/packages/server/resources/manual/02-chat.md +30 -13
- package/packages/server/resources/manual/03-sessions.md +22 -1
- package/packages/server/resources/manual/04-slash-commands-favorites.md +2 -1
- package/packages/server/resources/manual/05-projects.md +1 -0
- package/packages/server/resources/manual/06-file-explorer-editor.md +21 -2
- package/packages/server/resources/manual/07-git.md +1 -1
- package/packages/server/resources/manual/08-terminal.md +1 -1
- package/packages/server/resources/manual/12-harness-workbench.md +21 -16
- package/packages/server/resources/manual/13-settings.md +22 -6
- package/packages/server/resources/manual/14-keyboard-shortcuts.md +8 -1
- package/packages/shared/dist/constants/messageTree.d.ts +2 -0
- package/packages/shared/dist/constants/messageTree.d.ts.map +1 -1
- package/packages/shared/dist/constants/messageTree.js +6 -0
- package/packages/shared/dist/constants/messageTree.js.map +1 -1
- package/packages/shared/dist/index.d.ts +1 -1
- package/packages/shared/dist/index.d.ts.map +1 -1
- package/packages/shared/dist/index.js.map +1 -1
- package/packages/shared/dist/types/accounts.d.ts +13 -0
- package/packages/shared/dist/types/accounts.d.ts.map +1 -1
- package/packages/shared/dist/types/history.d.ts +14 -1
- package/packages/shared/dist/types/history.d.ts.map +1 -1
- package/packages/shared/dist/types/message.d.ts +26 -3
- package/packages/shared/dist/types/message.d.ts.map +1 -1
- package/packages/shared/dist/types/message.js +21 -1
- package/packages/shared/dist/types/message.js.map +1 -1
- package/packages/shared/dist/types/preferences.d.ts +5 -1
- package/packages/shared/dist/types/preferences.d.ts.map +1 -1
- package/packages/shared/dist/types/preferences.js +3 -0
- package/packages/shared/dist/types/preferences.js.map +1 -1
- package/packages/shared/dist/types/sdk.d.ts +68 -10
- package/packages/shared/dist/types/sdk.d.ts.map +1 -1
- package/packages/shared/dist/types/sdk.js +56 -16
- package/packages/shared/dist/types/sdk.js.map +1 -1
- package/packages/shared/dist/types/session.d.ts +6 -0
- package/packages/shared/dist/types/session.d.ts.map +1 -1
- package/packages/shared/dist/types/session.js +6 -0
- package/packages/shared/dist/types/session.js.map +1 -1
- package/packages/shared/dist/types/streaming.d.ts +32 -2
- package/packages/shared/dist/types/streaming.d.ts.map +1 -1
- package/packages/shared/dist/types/streaming.js.map +1 -1
- package/packages/shared/dist/types/websocket.d.ts +4 -0
- package/packages/shared/dist/types/websocket.d.ts.map +1 -1
- package/scripts/probe-persistent-hooks.mjs +795 -0
- package/packages/client/dist/assets/index-BHbx57gc.js +0 -2
- package/packages/client/dist/assets/index-BX7y_Qt3.css +0 -32
- 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 {
|
|
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
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
1128
|
-
// This ensures passive viewers (who didn't initiate
|
|
1129
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
|
|
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
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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
|
-
//
|
|
2268
|
-
//
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 >
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
2647
|
-
//
|
|
2648
|
-
//
|
|
2649
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|