@hienlh/ppm 0.8.85 → 0.8.86

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 (224) hide show
  1. package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/CHANGELOG.md +5 -187
  4. package/bun.lock +0 -5
  5. package/dist/web/assets/{_basePickBy-5PGDJbfF.js → _basePickBy-5eBmZ_lt.js} +1 -1
  6. package/dist/web/assets/{_baseUniq-BT4Ow4Kk.js → _baseUniq-DimLlN0y.js} +1 -1
  7. package/dist/web/assets/api-settings-CFw-lh5k.js +1 -0
  8. package/dist/web/assets/{arc-BAOivWpI.js → arc-D4SasZrA.js} +1 -1
  9. package/dist/web/assets/architecture-PBZL5I3N-CJupe6q_.js +1 -0
  10. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-DWBCPMLF.js → architectureDiagram-2XIMDMQ5-nv0WbM7d.js} +1 -1
  11. package/dist/web/assets/{blockDiagram-WCTKOSBZ-TEF8Ally.js → blockDiagram-WCTKOSBZ-C1XvYrb8.js} +1 -1
  12. package/dist/web/assets/browser-tab-CmsL5eny.js +1 -0
  13. package/dist/web/assets/{c4Diagram-IC4MRINW-dV22iAsY.js → c4Diagram-IC4MRINW-CygDrbWJ.js} +1 -1
  14. package/dist/web/assets/channel-DmKoFTd_.js +1 -0
  15. package/dist/web/assets/chat-tab-CFWsf13Z.js +7 -0
  16. package/dist/web/assets/{chunk-4BX2VUAB-D4tOov49.js → chunk-4BX2VUAB-C2FDgsgT.js} +1 -1
  17. package/dist/web/assets/{chunk-55IACEB6-DJ6BynZ4.js → chunk-55IACEB6-jF4w6cat.js} +1 -1
  18. package/dist/web/assets/{chunk-7E7YKBS2-CiyUJxNI.js → chunk-7E7YKBS2-BVCECZFi.js} +1 -1
  19. package/dist/web/assets/{chunk-7R4GIKGN-BbIFzsIv.js → chunk-7R4GIKGN-DXTbeu5d.js} +2 -2
  20. package/dist/web/assets/{chunk-C72U2L5F-D21mS_6G.js → chunk-C72U2L5F-BaZqOsTs.js} +1 -1
  21. package/dist/web/assets/{chunk-EGIJ26TM-DzqmU2Z7.js → chunk-EGIJ26TM-Bky2tcH7.js} +1 -1
  22. package/dist/web/assets/{chunk-FMBD7UC4-DXncblvW.js → chunk-FMBD7UC4-Cp4BK9A8.js} +1 -1
  23. package/dist/web/assets/{chunk-GEFDOKGD-BbQkJu8C.js → chunk-GEFDOKGD-BosFEH7G.js} +1 -1
  24. package/dist/web/assets/chunk-GLR3WWYH-BnP-hOp6.js +2 -0
  25. package/dist/web/assets/chunk-HHEYEP7N-DKDPTPEZ.js +1 -0
  26. package/dist/web/assets/{chunk-JSJVCQXG-23tyvw8k.js → chunk-JSJVCQXG-H5Gbjsbr.js} +1 -1
  27. package/dist/web/assets/{chunk-KX2RTZJC-sQ0o-39C.js → chunk-KX2RTZJC-CWerSUwS.js} +1 -1
  28. package/dist/web/assets/{chunk-KYZI473N-BcUZNnwd.js → chunk-KYZI473N-FvwP7jUy.js} +1 -1
  29. package/dist/web/assets/{chunk-L3YUKLVL-C7qGJrfV.js → chunk-L3YUKLVL-D1PI_ORP.js} +1 -1
  30. package/dist/web/assets/{chunk-MX3YWQON-BpS_PtKp.js → chunk-MX3YWQON-C7Vzk_AI.js} +1 -1
  31. package/dist/web/assets/{chunk-NQ4KR5QH-wMgTlP7f.js → chunk-NQ4KR5QH-BceYBGYX.js} +1 -1
  32. package/dist/web/assets/{chunk-O4XLMI2P-JC6EGoUz.js → chunk-O4XLMI2P-WPtzgxql.js} +1 -1
  33. package/dist/web/assets/{chunk-OZEHJAEY-BXhYx3nO.js → chunk-OZEHJAEY-DlHXDeLY.js} +1 -1
  34. package/dist/web/assets/{chunk-PQ6SQG4A-D6BTbCQw.js → chunk-PQ6SQG4A-Ci_Prygb.js} +1 -1
  35. package/dist/web/assets/{chunk-PU5JKC2W-Dw8ClWch.js → chunk-PU5JKC2W-CO0zMN-z.js} +1 -1
  36. package/dist/web/assets/chunk-QZHKN3VN-C_wpI9wz.js +1 -0
  37. package/dist/web/assets/{chunk-R5LLSJPH-CFwSJijQ.js → chunk-R5LLSJPH-IAEEzfpM.js} +1 -1
  38. package/dist/web/assets/{chunk-WL4C6EOR-DfofndiH.js → chunk-WL4C6EOR-BLXalOgc.js} +1 -1
  39. package/dist/web/assets/{chunk-XIRO2GV7-Djlmrely.js → chunk-XIRO2GV7-Dx1Ri_p2.js} +1 -1
  40. package/dist/web/assets/{chunk-XPW4576I-BPQQBakK.js → chunk-XPW4576I-m9pPGKn7.js} +1 -1
  41. package/dist/web/assets/{chunk-XZSTWKYB-DxAOx4hG.js → chunk-XZSTWKYB-B_08ExbI.js} +1 -1
  42. package/dist/web/assets/{chunk-YBOYWFTD-CeU4Q-xC.js → chunk-YBOYWFTD-DqSOVcYe.js} +1 -1
  43. package/dist/web/assets/classDiagram-VBA2DB6C-B1T5uY-F.js +1 -0
  44. package/dist/web/assets/classDiagram-v2-RAHNMMFH-xs5vI3xC.js +1 -0
  45. package/dist/web/assets/clone-CijCFRT5.js +1 -0
  46. package/dist/web/assets/code-editor-H_dAh_fJ.js +1 -0
  47. package/dist/web/assets/{cose-bilkent-S5V4N54A-B_AWZsOP.js → cose-bilkent-S5V4N54A-DlL82QHu.js} +1 -1
  48. package/dist/web/assets/{dagre-Dbb5k38K.js → dagre-BmVoh2At.js} +1 -1
  49. package/dist/web/assets/{dagre-KLK3FWXG-BH7aWGRP.js → dagre-KLK3FWXG-sDrRW9MQ.js} +1 -1
  50. package/dist/web/assets/database-viewer-DBzsgEJ8.js +1 -0
  51. package/dist/web/assets/{diagram-E7M64L7V-B1Qz70Do.js → diagram-E7M64L7V-ChnAhgni.js} +1 -1
  52. package/dist/web/assets/{diagram-IFDJBPK2-k55eVqVU.js → diagram-IFDJBPK2-DW1J1uJd.js} +1 -1
  53. package/dist/web/assets/{diagram-P4PSJMXO-BkfNRc9U.js → diagram-P4PSJMXO-CQ32hyG_.js} +1 -1
  54. package/dist/web/assets/diff-viewer-DzS-OnAR.js +4 -0
  55. package/dist/web/assets/dist-0Va_2L7G.js +16 -0
  56. package/dist/web/assets/dist-D9irYETY.js +41 -0
  57. package/dist/web/assets/{erDiagram-INFDFZHY-CKzVujYI.js → erDiagram-INFDFZHY-6CHo6nOw.js} +1 -1
  58. package/dist/web/assets/{flowDiagram-PKNHOUZH-DIqcTrDV.js → flowDiagram-PKNHOUZH-DroDiNT0.js} +1 -1
  59. package/dist/web/assets/{ganttDiagram-A5KZAMGK-D4v7ZbVE.js → ganttDiagram-A5KZAMGK-DP0QBh8w.js} +1 -1
  60. package/dist/web/assets/git-graph-D3C7F8o3.js +1 -0
  61. package/dist/web/assets/gitGraph-HDMCJU4V-B0KvGQG8.js +1 -0
  62. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-BTXo57mF.js → gitGraphDiagram-K3NZZRJ6-DvU3JGZn.js} +1 -1
  63. package/dist/web/assets/{graphlib-BcsNnGcW.js → graphlib-CQBb2thr.js} +1 -1
  64. package/dist/web/assets/index-CIkjfera.js +31 -0
  65. package/dist/web/assets/index-WKLuYsBY.css +2 -0
  66. package/dist/web/assets/info-3K5VOQVL-1uJ6_hCm.js +1 -0
  67. package/dist/web/assets/infoDiagram-LFFYTUFH-DLA5Q-3y.js +2 -0
  68. package/dist/web/assets/input-CGp1nFIg.js +1 -0
  69. package/dist/web/assets/{isEmpty-bnrF3Qbc.js → isEmpty-B4kqZBtn.js} +1 -1
  70. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-BOyvKMmB.js → ishikawaDiagram-PHBUUO56-46yibrV5.js} +1 -1
  71. package/dist/web/assets/{journeyDiagram-4ABVD52K-ufoasAy6.js → journeyDiagram-4ABVD52K-BcmRwjK-.js} +1 -1
  72. package/dist/web/assets/{kanban-definition-K7BYSVSG-Bi0UTUeN.js → kanban-definition-K7BYSVSG-B619K53y.js} +1 -1
  73. package/dist/web/assets/keybindings-store-BdaoLwSo.js +1 -0
  74. package/dist/web/assets/{line-B78g-52T.js → line-1gcO63_w.js} +1 -1
  75. package/dist/web/assets/{linear-DP4mkX3m.js → linear-DfRqDoVd.js} +1 -1
  76. package/dist/web/assets/markdown-renderer-DH49Zag7.js +69 -0
  77. package/dist/web/assets/{mermaid-parser.core-DMIWdgEW.js → mermaid-parser.core-XtjZQOeM.js} +2 -2
  78. package/dist/web/assets/{mindmap-definition-YRQLILUH-BsfWvIoO.js → mindmap-definition-YRQLILUH-CifOFo_q.js} +1 -1
  79. package/dist/web/assets/{ordinal-_K3x1fkz.js → ordinal-BJYw-iDX.js} +1 -1
  80. package/dist/web/assets/packet-RMMSAZCW-34C4o9yj.js +1 -0
  81. package/dist/web/assets/pie-UPGHQEXC-D9ekKlh9.js +1 -0
  82. package/dist/web/assets/{pieDiagram-SKSYHLDU-WP0XXw51.js → pieDiagram-SKSYHLDU-BuHUh_fO.js} +1 -1
  83. package/dist/web/assets/postgres-viewer-B9FYk8sD.js +1 -0
  84. package/dist/web/assets/{quadrantDiagram-337W2JSQ-FHMogtsh.js → quadrantDiagram-337W2JSQ-Bau_hj6Z.js} +1 -1
  85. package/dist/web/assets/radar-KQ55EAFF-DEuXOXSD.js +1 -0
  86. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-BatTxyWb.js → requirementDiagram-Z7DCOOCP-Cq2b-uwp.js} +1 -1
  87. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-ClJuW3Hv.js → sankeyDiagram-WA2Y5GQK-DrdGQxWQ.js} +1 -1
  88. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-ByxQqGgs.js → sequenceDiagram-2WXFIKYE-qPxiTUcS.js} +1 -1
  89. package/dist/web/assets/settings-store-DWXGVHsE.js +2 -0
  90. package/dist/web/assets/settings-tab-D-q8pd-5.js +1 -0
  91. package/dist/web/assets/sqlite-viewer-CDqcTePw.js +1 -0
  92. package/dist/web/assets/{stateDiagram-RAJIS63D-f8opcZNY.js → stateDiagram-RAJIS63D-Dulj2oa8.js} +1 -1
  93. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CAkzLlhk.js +1 -0
  94. package/dist/web/assets/tab-store-BPeiymiH.js +1 -0
  95. package/dist/web/assets/{terminal-tab-CCDLZA5Y.js → terminal-tab-wKgpSPAT.js} +2 -2
  96. package/dist/web/assets/{timeline-definition-YZTLITO2-58BlOSf9.js → timeline-definition-YZTLITO2-BWyDnCYq.js} +1 -1
  97. package/dist/web/assets/treemap-KZPCXAKY-nc7a1Ia1.js +1 -0
  98. package/dist/web/assets/use-monaco-theme-CCBTQ0S3.js +11 -0
  99. package/dist/web/assets/{vennDiagram-LZ73GAT5-BOSy9ma9.js → vennDiagram-LZ73GAT5-B9Iv2bNV.js} +1 -1
  100. package/dist/web/assets/{xychartDiagram-JWTSCODW-z5MVJauZ.js → xychartDiagram-JWTSCODW-ChXcMzBQ.js} +1 -1
  101. package/dist/web/index.html +11 -12
  102. package/dist/web/sw.js +1 -1
  103. package/docs/code-standards.md +7 -232
  104. package/docs/codebase-summary.md +3 -9
  105. package/docs/design-guidelines.md +0 -21
  106. package/docs/project-changelog.md +1 -115
  107. package/docs/project-roadmap.md +19 -41
  108. package/docs/system-architecture.md +15 -212
  109. package/package.json +2 -3
  110. package/src/cli/commands/autostart.ts +1 -1
  111. package/src/cli/commands/restart.ts +1 -9
  112. package/src/cli/commands/status.ts +0 -19
  113. package/src/index.ts +3 -2
  114. package/src/providers/claude-agent-sdk.ts +31 -94
  115. package/src/providers/mock-provider.ts +1 -6
  116. package/src/server/index.ts +166 -38
  117. package/src/server/routes/chat.ts +3 -52
  118. package/src/server/routes/project-scoped.ts +0 -2
  119. package/src/server/routes/proxy.ts +53 -46
  120. package/src/server/routes/tunnel.ts +32 -0
  121. package/src/server/ws/chat.ts +146 -207
  122. package/src/services/account-selector.service.ts +8 -16
  123. package/src/services/account.service.ts +13 -19
  124. package/src/services/claude-usage.service.ts +11 -48
  125. package/src/services/cloud.service.ts +6 -10
  126. package/src/services/db.service.ts +6 -111
  127. package/src/services/port-tunnel.service.ts +97 -0
  128. package/src/services/proxy.service.ts +19 -4
  129. package/src/services/supervisor.ts +25 -285
  130. package/src/types/api.ts +1 -9
  131. package/src/types/chat.ts +1 -3
  132. package/src/web/app.tsx +35 -41
  133. package/src/web/components/browser/browser-tab.tsx +97 -106
  134. package/src/web/components/chat/chat-history-bar.tsx +6 -72
  135. package/src/web/components/chat/chat-tab.tsx +16 -32
  136. package/src/web/components/chat/message-input.tsx +13 -107
  137. package/src/web/components/chat/message-list.tsx +15 -27
  138. package/src/web/components/chat/session-picker.tsx +31 -78
  139. package/src/web/components/chat/usage-badge.tsx +1 -11
  140. package/src/web/components/editor/code-editor.tsx +26 -36
  141. package/src/web/components/layout/command-palette.tsx +1 -3
  142. package/src/web/components/layout/editor-panel.tsx +18 -162
  143. package/src/web/components/layout/panel-layout.tsx +1 -17
  144. package/src/web/components/settings/proxy-settings-section.tsx +42 -40
  145. package/src/web/hooks/use-chat.ts +201 -211
  146. package/src/web/hooks/use-global-keybindings.ts +2 -25
  147. package/src/web/hooks/use-server-reload.ts +0 -9
  148. package/src/web/hooks/use-url-sync.ts +21 -173
  149. package/src/web/stores/keybindings-store.ts +0 -1
  150. package/src/web/stores/panel-store.ts +19 -73
  151. package/src/web/stores/panel-utils.ts +3 -145
  152. package/dist/web/assets/api-settings-Bx1GaNmQ.js +0 -1
  153. package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +0 -1
  154. package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
  155. package/dist/web/assets/browser-tab-DaHGm_0i.js +0 -1
  156. package/dist/web/assets/channel-wrd-NHWf.js +0 -1
  157. package/dist/web/assets/chat-tab-BDYE0KHF.js +0 -8
  158. package/dist/web/assets/chevron-right-DeV0ehiG.js +0 -1
  159. package/dist/web/assets/chunk-GLR3WWYH-CzYx4w-r.js +0 -2
  160. package/dist/web/assets/chunk-HHEYEP7N-HRhYy3kG.js +0 -1
  161. package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +0 -1
  162. package/dist/web/assets/classDiagram-VBA2DB6C-lse8oZoJ.js +0 -1
  163. package/dist/web/assets/classDiagram-v2-RAHNMMFH-CxkwuInd.js +0 -1
  164. package/dist/web/assets/clone-LRxlvnMj.js +0 -1
  165. package/dist/web/assets/code-editor-DTA3c9Y8.js +0 -2
  166. package/dist/web/assets/csv-preview-DLqYtXxt.js +0 -10
  167. package/dist/web/assets/database-viewer-DXk79Nel.js +0 -1
  168. package/dist/web/assets/diff-viewer-HhIcsOQE.js +0 -4
  169. package/dist/web/assets/dist-DylI9XxN.js +0 -13
  170. package/dist/web/assets/dist-lF8CoYII.js +0 -41
  171. package/dist/web/assets/git-graph-CQtWu8yE.js +0 -1
  172. package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +0 -1
  173. package/dist/web/assets/index-CgQXpBb_.css +0 -2
  174. package/dist/web/assets/index-DEeeRoka.js +0 -37
  175. package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +0 -1
  176. package/dist/web/assets/infoDiagram-LFFYTUFH-B1CX0pbC.js +0 -2
  177. package/dist/web/assets/input-BglMT33g.js +0 -1
  178. package/dist/web/assets/keybindings-store-1CJ7VX57.js +0 -1
  179. package/dist/web/assets/lib-BQ34Db2e.js +0 -4
  180. package/dist/web/assets/markdown-renderer-Brj8_LQM.js +0 -69
  181. package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +0 -1
  182. package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +0 -1
  183. package/dist/web/assets/postgres-viewer-CwkTGmqy.js +0 -1
  184. package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +0 -1
  185. package/dist/web/assets/react-dom-Bpkvzu3U.js +0 -1
  186. package/dist/web/assets/settings-tab-BDE1MsIh.js +0 -1
  187. package/dist/web/assets/sqlite-viewer-CFYTwgA8.js +0 -1
  188. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-DrxVDY9q.js +0 -1
  189. package/dist/web/assets/tab-store-BJw7OCmy.js +0 -1
  190. package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +0 -1
  191. package/dist/web/assets/use-monaco-theme-CNzekTN3.js +0 -11
  192. package/docs/streaming-input-guide.md +0 -267
  193. package/snapshot-state.md +0 -1526
  194. package/src/server/routes/browser-preview.ts +0 -159
  195. package/src/server/routes/workspace.ts +0 -35
  196. package/src/services/cloud-ws.service.ts +0 -227
  197. package/src/web/components/chat/account-rotation-settings.tsx +0 -163
  198. package/src/web/components/chat/chat-welcome.tsx +0 -148
  199. package/src/web/components/editor/csv-preview.tsx +0 -228
  200. package/src/web/components/editor/editor-breadcrumb.tsx +0 -216
  201. package/src/web/components/editor/editor-toolbar.tsx +0 -74
  202. package/src/web/components/shared/connection-lost-overlay.tsx +0 -89
  203. package/src/web/hooks/use-voice-input.ts +0 -111
  204. package/src/web/lib/csv-parser.ts +0 -134
  205. package/src/web/stores/connection-store.ts +0 -39
  206. package/test-tokens.mjs +0 -212
  207. /package/dist/web/assets/{api-client-BfBM3I7n.js → api-client-DOElml5u.js} +0 -0
  208. /package/dist/web/assets/{array-B9UHiPd-.js → array-CYkMkqnU.js} +0 -0
  209. /package/dist/web/assets/{columns-2-DpsNbZOc.js → columns-2-ChOTgl3e.js} +0 -0
  210. /package/dist/web/assets/{cytoscape.esm-BW-DbntU.js → cytoscape.esm-HeHO0VhB.js} +0 -0
  211. /package/dist/web/assets/{defaultLocale-5eAKkKJC.js → defaultLocale-Beh6XjaL.js} +0 -0
  212. /package/dist/web/assets/{dist-CSJdAyA9.js → dist-BUYzeuKe.js} +0 -0
  213. /package/dist/web/assets/{init-DlZdxViB.js → init-Rr1s_RiX.js} +0 -0
  214. /package/dist/web/assets/{isArrayLikeObject-B_v2FtYn.js → isArrayLikeObject-BB-mzMLb.js} +0 -0
  215. /package/dist/web/assets/{katex-Bqvo_ZG0.js → katex-CKoArbIw.js} +0 -0
  216. /package/dist/web/assets/{math-069Z4SuC.js → math-B7b0HgJF.js} +0 -0
  217. /package/dist/web/assets/{path-6uRLdFF7.js → path-BAQ3hXlG.js} +0 -0
  218. /package/dist/web/assets/{preload-helper-uTix4PVD.js → preload-helper-DeiOTZKJ.js} +0 -0
  219. /package/dist/web/assets/{react-ER-4DN55.js → react-Dev-wu-s.js} +0 -0
  220. /package/dist/web/assets/{rough.esm-JX0wREDd.js → rough.esm-Dwml_la6.js} +0 -0
  221. /package/dist/web/assets/{src-BqX54PbV.js → src-B_cC68fH.js} +0 -0
  222. /package/dist/web/assets/{table-C7X5UAEI.js → table-COiJDPRA.js} +0 -0
  223. /package/dist/web/assets/{tag-CCtdV063.js → tag-LMq02LfE.js} +0 -0
  224. /package/dist/web/assets/{utils-BNytJOb1.js → utils-btZ8C8-R.js} +0 -0
@@ -3,16 +3,10 @@ import { providerRegistry } from "../../providers/registry.ts";
3
3
  import { resolveProjectPath } from "../helpers/resolve-project.ts";
4
4
  import { logSessionEvent } from "../../services/session-log.service.ts";
5
5
  import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
6
- import { getSessionTitle } from "../../services/db.service.ts";
7
- import type { ChatWsClientMessage, SessionPhase } from "../../types/api.ts";
6
+ import type { ChatWsClientMessage } from "../../types/api.ts";
8
7
 
9
8
  const PING_INTERVAL_MS = 15_000; // 15s keepalive
10
9
  const CLEANUP_TIMEOUT_MS = 5 * 60_000; // 5min after Claude done + no FE
11
- const MAX_TURN_EVENTS = 10_000; // memory safety cap
12
- const BUFFERABLE_TYPES = new Set([
13
- "text", "thinking", "tool_use", "tool_result",
14
- "approval_request", "error", "done", "account_info", "account_retry",
15
- ]);
16
10
 
17
11
  type ChatWsSocket = {
18
12
  data: { type: string; sessionId: string; projectName?: string };
@@ -22,16 +16,21 @@ type ChatWsSocket = {
22
16
 
23
17
  interface SessionEntry {
24
18
  providerId: string;
25
- clients: Set<ChatWsSocket>;
19
+ ws: ChatWsSocket | null;
26
20
  abort?: AbortController;
27
21
  projectPath?: string;
28
22
  projectName?: string;
29
- pingIntervals: Map<ChatWsSocket, ReturnType<typeof setInterval>>;
30
- phase: SessionPhase;
23
+ pingInterval?: ReturnType<typeof setInterval>;
24
+ isStreaming: boolean;
31
25
  cleanupTimer?: ReturnType<typeof setTimeout>;
32
26
  pendingApprovalEvent?: { type: string; requestId: string; tool: string; input: unknown };
33
- turnEvents: unknown[];
27
+ /** When true, accumulate text events until next turn boundary, then flush as one message */
28
+ needsCatchUp: boolean;
29
+ /** Accumulated text content during catch-up phase */
30
+ catchUpText: string;
31
+ /** Reference to the running stream promise — prevents GC */
34
32
  streamPromise?: Promise<void>;
33
+ /** Sticky permission mode for this session */
35
34
  permissionMode?: string;
36
35
  }
37
36
 
@@ -41,80 +40,26 @@ const activeSessions = new Map<string, SessionEntry>();
41
40
  /** Check if any frontend client is currently connected via WebSocket */
42
41
  export function hasActiveClient(): boolean {
43
42
  for (const entry of activeSessions.values()) {
44
- if (entry.clients.size > 0) return true;
43
+ if (entry.ws) return true;
45
44
  }
46
45
  return false;
47
46
  }
48
47
 
49
- /** Remove a client from the session, cleaning up its ping interval */
50
- function evictClient(entry: SessionEntry, ws: ChatWsSocket): void {
51
- clearClientPing(entry, ws);
52
- entry.clients.delete(ws);
53
- }
54
-
55
- /** Broadcast event to all connected clients for a session */
56
- function broadcast(sessionId: string, event: unknown): void {
48
+ /** Send event to FE if connected, silently drop otherwise */
49
+ function safeSend(sessionId: string, event: unknown): void {
57
50
  const entry = activeSessions.get(sessionId);
58
- if (!entry || entry.clients.size === 0) {
51
+ if (!entry?.ws) {
59
52
  const evType = (event as any)?.type ?? "unknown";
60
- if (evType !== "ping" && evType !== "phase_changed") {
61
- console.warn(`[chat] session=${sessionId} broadcast: no clients, dropping ${evType}`);
53
+ // Log ALL dropped events (including streaming_status) for debugging first-message issues
54
+ if (evType !== "ping") {
55
+ console.warn(`[chat] session=${sessionId} safeSend: ws=null, dropping ${evType}`);
62
56
  }
63
57
  return;
64
58
  }
65
- const json = JSON.stringify(event);
66
- for (const client of entry.clients) {
67
- try { client.send(json); } catch { evictClient(entry, client); }
68
- }
69
- }
70
-
71
- /** Buffer event in turnEvents + broadcast to all clients */
72
- function bufferAndBroadcast(sessionId: string, event: unknown): void {
73
- const entry = activeSessions.get(sessionId);
74
- if (!entry) return;
75
- const evType = (event as any)?.type;
76
- if (evType && BUFFERABLE_TYPES.has(evType)) {
77
- if (entry.turnEvents.length < MAX_TURN_EVENTS) {
78
- entry.turnEvents.push({ ...(event as Record<string, unknown>) });
79
- }
80
- }
81
- broadcast(sessionId, event);
82
- }
83
-
84
- /** Transition session phase — guards same-phase, broadcasts phase_changed */
85
- function setPhase(sessionId: string, phase: SessionPhase, elapsed?: number): void {
86
- const entry = activeSessions.get(sessionId);
87
- if (!entry || entry.phase === phase) return;
88
- entry.phase = phase;
89
- broadcast(sessionId, { type: "phase_changed", phase, ...(elapsed != null ? { elapsed } : {}) });
90
- console.log(`[chat] session=${sessionId} phase → ${phase}`);
91
- }
92
-
93
- /** Send buffered turn events to a single client (reconnect sync) */
94
- function sendTurnEvents(sessionId: string, ws: ChatWsSocket): void {
95
- const entry = activeSessions.get(sessionId);
96
- if (!entry || entry.turnEvents.length === 0) return;
97
59
  try {
98
- ws.send(JSON.stringify({ type: "turn_events", events: entry.turnEvents }));
60
+ entry.ws.send(JSON.stringify(event));
99
61
  } catch (e) {
100
- console.warn(`[chat] session=${sessionId} sendTurnEvents failed: ${(e as Error).message}`);
101
- }
102
- }
103
-
104
- /** Set up per-client application-level ping */
105
- function setupClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
106
- const interval = setInterval(() => {
107
- try { ws.send(JSON.stringify({ type: "ping" })); } catch { /* ws may be closed */ }
108
- }, PING_INTERVAL_MS);
109
- entry.pingIntervals.set(ws, interval);
110
- }
111
-
112
- /** Clear per-client ping */
113
- function clearClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
114
- const interval = entry.pingIntervals.get(ws);
115
- if (interval) {
116
- clearInterval(interval);
117
- entry.pingIntervals.delete(ws);
62
+ console.warn(`[chat] session=${sessionId} safeSend: send failed (${(e as Error).message})`);
118
63
  }
119
64
  }
120
65
 
@@ -126,8 +71,7 @@ function startCleanupTimer(sessionId: string): void {
126
71
  entry.cleanupTimer = setTimeout(() => {
127
72
  console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
128
73
  logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
129
- for (const interval of entry.pingIntervals.values()) clearInterval(interval);
130
- entry.pingIntervals.clear();
74
+ if (entry.pingInterval) clearInterval(entry.pingInterval);
131
75
  activeSessions.delete(sessionId);
132
76
  }, CLEANUP_TIMEOUT_MS);
133
77
  }
@@ -143,29 +87,39 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
143
87
  return;
144
88
  }
145
89
  const streamStartMs = Date.now();
146
- console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
90
+ console.log(`[chat] session=${sessionId} runStreamLoop started (ws=${entry.ws ? "connected" : "null"})`);
147
91
 
148
92
  const abortController = new AbortController();
149
93
  entry.abort = abortController;
94
+ entry.isStreaming = true;
150
95
  entry.pendingApprovalEvent = undefined;
151
- entry.turnEvents = [];
152
- setPhase(sessionId, "connecting");
96
+ entry.needsCatchUp = false;
97
+ entry.catchUpText = "";
153
98
 
99
+ // Heartbeat interval — declared outside try so finally can clear it
154
100
  let heartbeat: ReturnType<typeof setInterval> | undefined;
155
101
  let lastContextWindowPct: number | undefined;
156
- let doneEmitted = false;
157
102
 
158
103
  try {
159
104
  const userPreview = content.slice(0, 200);
160
105
  logSessionEvent(sessionId, "USER", userPreview);
161
106
  console.log(`[chat] session=${sessionId} sending message to provider=${providerId}`);
162
107
 
108
+ // Send "connecting" status with thinking config so FE can set appropriate warning threshold
109
+ const { configService } = await import("../../services/config.service.ts");
110
+ const ai = configService.get("ai");
111
+ const pCfg = ai.providers[ai.default_provider ?? "claude"] ?? {};
112
+ const effort = (pCfg as Record<string, unknown>).effort as string | undefined;
113
+ const thinkingBudget = (pCfg as Record<string, unknown>).thinking_budget_tokens as number | undefined;
114
+ safeSend(sessionId, { type: "streaming_status", status: "connecting", effort, thinkingBudget });
115
+
163
116
  let eventCount = 0;
164
117
  let firstEventReceived = false;
165
118
  const startTime = Date.now();
166
119
 
167
120
  // Heartbeat: while waiting for first response, send elapsed time every 5s
168
- const CONNECTION_TIMEOUT_S = 120;
121
+ // so FE can show "Connecting... (15s)" and warn if it takes too long
122
+ const CONNECTION_TIMEOUT_S = 120; // 2min max wait for first SDK event
169
123
  heartbeat = setInterval(() => {
170
124
  if (firstEventReceived || abortController.signal.aborted) {
171
125
  clearInterval(heartbeat);
@@ -182,15 +136,14 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
182
136
  ? "\n\nWSL detected — this is likely a network issue. Try from your WSL terminal:\n curl -s https://api.anthropic.com\nIf that fails, check WSL DNS settings (/etc/resolv.conf) or proxy configuration."
183
137
  : "";
184
138
  const debugCmd = projectPath ? `cd ${projectPath} && claude -p "hi"` : `claude -p "hi"`;
185
- bufferAndBroadcast(sessionId, {
139
+ safeSend(sessionId, {
186
140
  type: "error",
187
141
  message: `Claude SDK timed out after ${elapsed}s for project "${projectPath || "(no project)"}".${wslHint}\n\nDebug steps:\n1. Run: \`${debugCmd}\` — if it also hangs, the issue is your Claude CLI environment\n2. Check env vars: \`echo $ANTHROPIC_API_KEY $ANTHROPIC_BASE_URL\` — stale/invalid keys cause silent hang\n3. Try with env cleared: \`ANTHROPIC_API_KEY="" ANTHROPIC_BASE_URL="" ${debugCmd}\`\n4. Check hooks/MCP: \`cat ${projectPath}/.claude/settings.local.json\`\n5. Refresh auth: \`claude login\``,
188
142
  });
189
143
  abortController.abort();
190
144
  return;
191
145
  }
192
- // Heartbeat uses broadcast() directly NOT setPhase() (same-phase guard would skip elapsed updates)
193
- broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
146
+ safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed });
194
147
  }, 5_000);
195
148
 
196
149
  for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
@@ -199,32 +152,18 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
199
152
  const ev = event as any;
200
153
  const evType = ev.type ?? "unknown";
201
154
 
202
- // System events (hook_started, init, etc.) transition connecting thinking
203
- // These indicate SDK has connected and is processing, but no content yet.
204
- if (evType === "system") {
205
- if (!firstEventReceived) {
206
- if (heartbeat) clearInterval(heartbeat);
207
- setPhase(sessionId, "thinking");
208
- }
209
- continue; // Don't buffer or broadcast system events
210
- }
211
-
212
- // First content event — stop heartbeat, transition phase
213
- const isMetadataEvent = evType === "account_info" || evType === "account_retry" || evType === "streaming_status";
155
+ // First content event stop heartbeat, switch to streaming status.
156
+ // Skip metadata events (account_info, streaming_status) that arrive before
157
+ // the SDK subprocess actually produces output — keeps heartbeat + "connecting"
158
+ // indicator alive until real content flows.
159
+ const isMetadataEvent = evType === "account_info" || evType === "streaming_status";
214
160
  if (!firstEventReceived && !isMetadataEvent) {
215
161
  firstEventReceived = true;
216
162
  const waitMs = Date.now() - startTime;
217
163
  console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType}`);
218
164
  logSessionEvent(sessionId, "PERF", `First SDK event after ${waitMs}ms (type=${evType})`);
219
165
  if (heartbeat) clearInterval(heartbeat);
220
- const newPhase = evType === "thinking" ? "thinking" : "streaming";
221
- setPhase(sessionId, newPhase);
222
- }
223
-
224
- // Dynamic phase transitions between thinking/streaming
225
- if (firstEventReceived) {
226
- if (evType === "text" && entry.phase === "thinking") setPhase(sessionId, "streaming");
227
- if (evType === "thinking" && entry.phase === "streaming") setPhase(sessionId, "thinking");
166
+ safeSend(sessionId, { type: "streaming_status", status: "streaming" });
228
167
  }
229
168
 
230
169
  // Log every event
@@ -239,16 +178,15 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
239
178
  console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
240
179
  logSessionEvent(sessionId, "ERROR", errorDetail);
241
180
  } else if (evType === "done") {
242
- doneEmitted = true;
243
181
  logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
244
182
  if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
245
- // Fire-and-forget: fetch updated session title (DB title takes priority)
183
+ // Fire-and-forget: fetch updated session title from SDK summary
246
184
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
247
185
  const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
248
- const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
249
- const title = dbTitle ?? found?.customTitle ?? found?.summary;
186
+ const title = found?.customTitle ?? found?.summary;
250
187
  if (title) {
251
- broadcast(sessionId, { type: "title_updated", title });
188
+ safeSend(sessionId, { type: "title_updated", title });
189
+ // Also update in-memory session title
252
190
  const session = chatService.getSession(sessionId);
253
191
  if (session) session.title = title;
254
192
  }
@@ -285,8 +223,22 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
285
223
  logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
286
224
  }
287
225
 
288
- // Buffer + broadcast content events
289
- bufferAndBroadcast(sessionId, event);
226
+ // Catch-up mode: accumulate text, flush on turn boundary
227
+ if (entry.needsCatchUp) {
228
+ if (evType === "text") {
229
+ entry.catchUpText += ev.content ?? "";
230
+ } else {
231
+ // Non-text event = turn boundary → flush accumulated text, then send this event
232
+ if (entry.catchUpText) {
233
+ safeSend(sessionId, { type: "text", content: entry.catchUpText });
234
+ }
235
+ entry.needsCatchUp = false;
236
+ entry.catchUpText = "";
237
+ safeSend(sessionId, event);
238
+ }
239
+ } else {
240
+ safeSend(sessionId, event);
241
+ }
290
242
  }
291
243
 
292
244
  logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
@@ -295,22 +247,19 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
295
247
  const errMsg = (e as Error).message;
296
248
  logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
297
249
  if (!abortController.signal.aborted) {
298
- bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
250
+ safeSend(sessionId, { type: "error", message: errMsg });
299
251
  }
300
252
  } finally {
301
253
  if (heartbeat) clearInterval(heartbeat);
302
- // 1. Buffer and broadcast done event (skip if SDK already yielded one)
303
- if (!doneEmitted) {
304
- bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
305
- }
306
- // 2. Clear buffer BEFORE setting phase to idle
307
- entry.turnEvents = [];
308
- // 3. Transition to idle
309
- setPhase(sessionId, "idle");
310
- // 4. Cleanup
254
+ // Always send done guarantees FE resets isStreaming even if provider didn't yield done
255
+ safeSend(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
311
256
  entry.abort = undefined;
257
+ entry.isStreaming = false;
312
258
  entry.pendingApprovalEvent = undefined;
313
- if (entry.clients.size === 0) {
259
+ entry.needsCatchUp = false;
260
+ entry.catchUpText = "";
261
+ // Claude is done — if no FE connected, start cleanup timer
262
+ if (!entry.ws) {
314
263
  startCleanupTimer(sessionId);
315
264
  }
316
265
  }
@@ -338,77 +287,77 @@ export const chatWebSocket = {
338
287
 
339
288
  const existing = activeSessions.get(sessionId);
340
289
  if (existing) {
341
- // FE reconnecting to existing session — clear cleanup timer
290
+ // FE reconnecting to existing session — replace ws, clear cleanup timer
342
291
  if (existing.cleanupTimer) {
343
292
  clearTimeout(existing.cleanupTimer);
344
293
  existing.cleanupTimer = undefined;
345
294
  }
295
+ if (existing.pingInterval) clearInterval(existing.pingInterval);
296
+ // Use application-level pings (JSON messages) instead of protocol-level ws.ping().
297
+ // Protocol-level pings can be intercepted by Cloudflare tunnels, causing the server
298
+ // to think the connection is alive when the data path to the client is broken.
299
+ existing.pingInterval = setInterval(() => {
300
+ try {
301
+ ws.send(JSON.stringify({ type: "ping" }));
302
+ } catch { /* ws may be closed */ }
303
+ }, PING_INTERVAL_MS);
304
+ existing.ws = ws;
346
305
  if (projectPath) existing.projectPath = projectPath;
347
306
  if (projectName) existing.projectName = projectName;
348
307
 
349
- // Send state + turnEvents BEFORE joining clients Set (ordering matters)
308
+ // If streaming, enter catch-up mode
309
+ if (existing.isStreaming) {
310
+ existing.needsCatchUp = true;
311
+ existing.catchUpText = "";
312
+ }
313
+
350
314
  ws.send(JSON.stringify({
351
- type: "session_state",
315
+ type: "status",
352
316
  sessionId,
353
- phase: existing.phase,
317
+ isStreaming: existing.isStreaming,
354
318
  pendingApproval: existing.pendingApprovalEvent ?? null,
355
319
  sessionTitle: session?.title || null,
356
320
  }));
357
-
358
- // If actively streaming, send buffered turn events for reconnect sync
359
- if (existing.phase !== "idle") {
360
- sendTurnEvents(sessionId, ws);
361
- }
362
-
363
- // NOW add to clients Set + set up ping
364
- existing.clients.add(ws);
365
- setupClientPing(existing, ws);
366
-
367
- // Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
321
+ // Async: resolve title from SDK if in-memory title is generic
368
322
  if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
369
323
  sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
370
324
  const found = sessions.find((s) => s.sessionId === sessionId);
371
- const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
372
- const title = dbTitle ?? found?.customTitle ?? found?.summary;
325
+ const title = found?.customTitle ?? found?.summary;
373
326
  if (title) {
374
- broadcast(sessionId, { type: "title_updated", title });
327
+ safeSend(sessionId, { type: "title_updated", title });
375
328
  if (session) session.title = title;
376
329
  }
377
330
  }).catch(() => {});
378
331
  }
379
- console.log(`[chat] session=${sessionId} FE reconnected (phase=${existing.phase}, clients=${existing.clients.size})`);
332
+ console.log(`[chat] session=${sessionId} FE reconnected (streaming=${existing.isStreaming}, catchUp=${existing.needsCatchUp})`);
380
333
  return;
381
334
  }
382
335
 
383
- // New session entry
384
- const newEntry: SessionEntry = {
336
+ // New session entry — use application-level pings for Cloudflare tunnel compatibility
337
+ const pingInterval = setInterval(() => {
338
+ try {
339
+ ws.send(JSON.stringify({ type: "ping" }));
340
+ } catch { /* ws may be closed */ }
341
+ }, PING_INTERVAL_MS);
342
+
343
+ activeSessions.set(sessionId, {
385
344
  providerId,
386
- clients: new Set([ws]),
345
+ ws,
387
346
  projectPath,
388
347
  projectName,
389
- pingIntervals: new Map(),
390
- phase: "idle",
391
- turnEvents: [],
392
- };
393
- activeSessions.set(sessionId, newEntry);
394
- setupClientPing(newEntry, ws);
395
-
396
- ws.send(JSON.stringify({
397
- type: "session_state",
398
- sessionId,
399
- phase: "idle",
400
- pendingApproval: null,
401
- sessionTitle: session?.title || null,
402
- }));
403
-
404
- // Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
348
+ pingInterval,
349
+ isStreaming: false,
350
+ needsCatchUp: false,
351
+ catchUpText: "",
352
+ });
353
+ ws.send(JSON.stringify({ type: "connected", sessionId, sessionTitle: session?.title || null }));
354
+ // Async: resolve title from SDK if in-memory title is generic
405
355
  if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
406
356
  sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
407
357
  const found = sessions.find((s) => s.sessionId === sessionId);
408
- const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
409
- const title = dbTitle ?? found?.customTitle ?? found?.summary;
358
+ const title = found?.customTitle ?? found?.summary;
410
359
  if (title) {
411
- broadcast(sessionId, { type: "title_updated", title });
360
+ safeSend(sessionId, { type: "title_updated", title });
412
361
  if (session) session.title = title;
413
362
  }
414
363
  }).catch(() => {});
@@ -428,6 +377,12 @@ export const chatWebSocket = {
428
377
  return;
429
378
  }
430
379
 
380
+ // Ensure entry.ws is current — may be stale if open/close race during reconnect
381
+ const entry0 = activeSessions.get(sessionId);
382
+ if (entry0 && entry0.ws !== ws) {
383
+ entry0.ws = ws;
384
+ }
385
+
431
386
  let entry = activeSessions.get(sessionId);
432
387
 
433
388
  // Auto-create entry if missing — handles: message before open (Bun race), or session cleaned up
@@ -437,21 +392,17 @@ export const chatWebSocket = {
437
392
  const pid = session?.providerId ?? providerRegistry.getDefault().id;
438
393
  let pp: string | undefined;
439
394
  if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
440
- const newEntry: SessionEntry = {
441
- providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
442
- pingIntervals: new Map(), phase: "idle", turnEvents: [],
443
- };
444
- activeSessions.set(sessionId, newEntry);
445
- setupClientPing(newEntry, ws);
446
- entry = newEntry;
395
+ const pi = setInterval(() => {
396
+ try { ws.send(JSON.stringify({ type: "ping" })); } catch { /* ws may be closed */ }
397
+ }, PING_INTERVAL_MS);
398
+ activeSessions.set(sessionId, {
399
+ providerId: pid, ws, projectPath: pp, projectName: pn,
400
+ pingInterval: pi, isStreaming: false, needsCatchUp: false, catchUpText: "",
401
+ });
402
+ entry = activeSessions.get(sessionId)!;
447
403
  console.log(`[chat] session=${sessionId} auto-created entry in message handler`);
448
404
  }
449
405
 
450
- // Ensure ws is in clients set
451
- if (!entry.clients.has(ws)) {
452
- entry.clients.add(ws);
453
- }
454
-
455
406
  const providerId = entry.providerId ?? providerRegistry.getDefault().id;
456
407
 
457
408
  // Client-initiated handshake — FE sends "ready" after onopen.
@@ -459,28 +410,24 @@ export const chatWebSocket = {
459
410
  // open-handler message still get connected/status confirmation.
460
411
  if (parsed.type === "ready") {
461
412
  ws.send(JSON.stringify({
462
- type: "session_state",
413
+ type: "status",
463
414
  sessionId,
464
- phase: entry.phase,
415
+ isStreaming: entry.isStreaming,
465
416
  pendingApproval: entry.pendingApprovalEvent ?? null,
466
- sessionTitle: chatService.getSession(sessionId)?.title || null,
467
417
  }));
468
- if (entry.phase !== "idle") {
469
- sendTurnEvents(sessionId, ws);
470
- }
471
418
  return;
472
419
  }
473
420
 
474
421
  if (parsed.type === "message") {
475
- if (typeof parsed.content !== "string" || !parsed.content.trim()) {
476
- ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
477
- return;
478
- }
479
422
  // Store permission mode — sticky for this session
480
423
  if (parsed.permissionMode) {
481
424
  entry.permissionMode = parsed.permissionMode;
482
425
  }
483
426
 
427
+ // Send immediate feedback BEFORE any async work — prevents "stuck thinking"
428
+ // when resumeSession is slow (e.g. sdkListSessions spawns subprocess on first call)
429
+ safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed: 0 });
430
+
484
431
  // Resume session in provider (can be slow on first call — sdkListSessions)
485
432
  const provider = providerRegistry.get(providerId);
486
433
  if (provider && "resumeSession" in provider) {
@@ -496,33 +443,24 @@ export const chatWebSocket = {
496
443
  (provider as any).ensureProjectPath(sessionId, entry.projectPath);
497
444
  }
498
445
 
499
- // Abort-and-replace: if already streaming, abort current query and wait for cleanup
500
- if (entry.phase !== "idle" && entry.abort) {
446
+ // If already streaming, abort current query first and wait for cleanup
447
+ if (entry.isStreaming && entry.abort) {
501
448
  console.log(`[chat] session=${sessionId} aborting current query for new message`);
502
449
  entry.abort.abort();
450
+ // Wait for stream loop to finish cleanup
503
451
  if (entry.streamPromise) {
504
452
  await entry.streamPromise;
505
453
  }
506
- // Re-fetch entry after await — may have been mutated during cleanup
507
- entry = activeSessions.get(sessionId)!;
508
- if (!entry) return;
509
454
  }
510
455
 
511
- // Reset for new query
512
- entry.turnEvents = [];
513
- setPhase(sessionId, "initializing");
514
-
515
456
  // Store promise reference on entry to prevent GC from collecting the async operation.
516
457
  // Use setTimeout(0) to detach from WS handler's async scope.
517
- const permMode = entry.permissionMode;
518
458
  entry.streamPromise = new Promise<void>((resolve) => {
519
459
  setTimeout(() => {
520
- runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
460
+ runStreamLoop(sessionId, providerId, parsed.content, entry.permissionMode).then(resolve, resolve);
521
461
  }, 0);
522
462
  });
523
463
  } else if (parsed.type === "cancel") {
524
- // Signal abortController so runStreamLoop suppresses error broadcast
525
- if (entry?.abort) entry.abort.abort();
526
464
  const provider = providerRegistry.get(providerId);
527
465
  if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
528
466
  (provider as any).abortQuery(sessionId);
@@ -532,11 +470,7 @@ export const chatWebSocket = {
532
470
  if (provider && typeof provider.resolveApproval === "function") {
533
471
  provider.resolveApproval(parsed.requestId, parsed.approved, (parsed as any).data);
534
472
  }
535
- if (entry) {
536
- entry.pendingApprovalEvent = undefined;
537
- // Broadcast approval cleared to all clients
538
- broadcast(sessionId, { type: "phase_changed", phase: entry.phase });
539
- }
473
+ if (entry) entry.pendingApprovalEvent = undefined;
540
474
  }
541
475
  },
542
476
 
@@ -545,11 +479,16 @@ export const chatWebSocket = {
545
479
  const entry = activeSessions.get(sessionId);
546
480
  if (!entry) return;
547
481
 
548
- // Remove from clients Set + clear per-client ping
549
- evictClient(entry, ws);
550
- console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
482
+ if (entry.pingInterval) {
483
+ clearInterval(entry.pingInterval);
484
+ entry.pingInterval = undefined;
485
+ }
486
+
487
+ // Detach FE — do NOT abort Claude
488
+ entry.ws = null;
489
+ console.log(`[chat] session=${sessionId} FE disconnected (streaming=${entry.isStreaming})`);
551
490
 
552
- if (entry.clients.size === 0 && entry.phase === "idle") {
491
+ if (!entry.isStreaming) {
553
492
  startCleanupTimer(sessionId);
554
493
  }
555
494
  },
@@ -57,14 +57,8 @@ class AccountSelectorService {
57
57
  // Clear expired cooldowns
58
58
  for (const acc of allAccounts) {
59
59
  if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
60
- try {
61
- accountService.setEnabled(acc.id);
62
- this.retryCounts.delete(acc.id);
63
- } catch {
64
- // Account expired or cannot be re-enabled — disable it
65
- accountService.setDisabled(acc.id);
66
- this.retryCounts.delete(acc.id);
67
- }
60
+ accountService.setEnabled(acc.id);
61
+ this.retryCounts.delete(acc.id);
68
62
  }
69
63
  }
70
64
 
@@ -124,14 +118,12 @@ class AccountSelectorService {
124
118
  * Weighted sustainability score.
125
119
  * Considers 5-hour utilization, weekly utilization, and time until weekly reset.
126
120
  *
127
- * score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio, 2.0) / 2.0
121
+ * score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio, 1.0)
128
122
  *
129
- * weeklyRemaining / resetRatio normalizes remaining capacity by time until reset.
130
- * Capped at 2.0 (not 1.0) so accounts with imminent reset score higher:
131
- * - 4% remaining with 34h left raw 0.20, scaled 0.10 (low)
132
- * - 78% remaining with 113h left raw 1.16, scaled 0.58 (good)
133
- * - 44% remaining with 32h left → raw 2.32, scaled 1.00 (great — resets soon)
134
- * - 20% remaining with 6h left → raw 5.6, scaled 1.00 (great — resets very soon)
123
+ * weeklyRemaining / resetRatio normalizes remaining capacity by time until reset:
124
+ * - 4% remaining with 34h left → low sustainability (0.20)
125
+ * - 78% remaining with 113h left high sustainability (1.0, capped)
126
+ * - 20% remaining with 6h left decent (resets soon, so it's fine)
135
127
  */
136
128
  private pickLowestUsage(active: { id: string; createdAt: number }[]): string {
137
129
  const scored = active.map((acc) => {
@@ -150,7 +142,7 @@ class AccountSelectorService {
150
142
  const immediate = 1 - fiveHour;
151
143
  const weeklyRemaining = 1 - weekly;
152
144
  const resetRatio = weeklyResetHours / 168;
153
- const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05), 2.0) / 2.0;
145
+ const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05), 1.0);
154
146
  const score = 0.35 * immediate + 0.65 * sustainability;
155
147
 
156
148
  return { id: acc.id, score, exhausted };