@hienlh/ppm 0.8.87 → 0.8.89

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 (190) hide show
  1. package/CHANGELOG.md +75 -40
  2. package/dist/web/assets/{_basePickBy-5PGDJbfF.js → _basePickBy-3Xe18azI.js} +1 -1
  3. package/dist/web/assets/{_baseUniq-BT4Ow4Kk.js → _baseUniq-Yy35llnn.js} +1 -1
  4. package/dist/web/assets/api-settings-Bid0NHuI.js +1 -0
  5. package/dist/web/assets/{arc-BAOivWpI.js → arc-B9n1Gvb5.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-CFzkFKEL.js +1 -0
  7. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-DWBCPMLF.js → architectureDiagram-2XIMDMQ5-DqAZP_F6.js} +1 -1
  8. package/dist/web/assets/{blockDiagram-WCTKOSBZ-TEF8Ally.js → blockDiagram-WCTKOSBZ-h3cDF2vI.js} +1 -1
  9. package/dist/web/assets/{browser-tab-DaHGm_0i.js → browser-tab-DSWumOSG.js} +1 -1
  10. package/dist/web/assets/{c4Diagram-IC4MRINW-dV22iAsY.js → c4Diagram-IC4MRINW--pF1r5lr.js} +1 -1
  11. package/dist/web/assets/channel-C2fMafck.js +1 -0
  12. package/dist/web/assets/chat-tab-Ccwf-c6M.js +8 -0
  13. package/dist/web/assets/{chunk-4BX2VUAB-D4tOov49.js → chunk-4BX2VUAB-C3aZvW7B.js} +1 -1
  14. package/dist/web/assets/{chunk-55IACEB6-DJ6BynZ4.js → chunk-55IACEB6-D5cABeB9.js} +1 -1
  15. package/dist/web/assets/{chunk-7E7YKBS2-CiyUJxNI.js → chunk-7E7YKBS2-CkFGv6Zs.js} +1 -1
  16. package/dist/web/assets/{chunk-7R4GIKGN-BbIFzsIv.js → chunk-7R4GIKGN-Dvbyu4Zw.js} +2 -2
  17. package/dist/web/assets/{chunk-C72U2L5F-D21mS_6G.js → chunk-C72U2L5F-CtqKiH4q.js} +1 -1
  18. package/dist/web/assets/{chunk-EGIJ26TM-DzqmU2Z7.js → chunk-EGIJ26TM-Cpr87sBR.js} +1 -1
  19. package/dist/web/assets/{chunk-FMBD7UC4-DXncblvW.js → chunk-FMBD7UC4-D23YVTOU.js} +1 -1
  20. package/dist/web/assets/{chunk-GEFDOKGD-BbQkJu8C.js → chunk-GEFDOKGD-tDjHsAUs.js} +1 -1
  21. package/dist/web/assets/chunk-GLR3WWYH-DBdWQ3zy.js +2 -0
  22. package/dist/web/assets/chunk-HHEYEP7N-BBw_z0fW.js +1 -0
  23. package/dist/web/assets/{chunk-JSJVCQXG-23tyvw8k.js → chunk-JSJVCQXG-BBmymCjA.js} +1 -1
  24. package/dist/web/assets/{chunk-KX2RTZJC-sQ0o-39C.js → chunk-KX2RTZJC-DP36BDiU.js} +1 -1
  25. package/dist/web/assets/{chunk-KYZI473N-BcUZNnwd.js → chunk-KYZI473N-Djw13C-3.js} +1 -1
  26. package/dist/web/assets/{chunk-L3YUKLVL-C7qGJrfV.js → chunk-L3YUKLVL-HG_eMj_C.js} +1 -1
  27. package/dist/web/assets/{chunk-MX3YWQON-BpS_PtKp.js → chunk-MX3YWQON-C2UEioMs.js} +1 -1
  28. package/dist/web/assets/{chunk-NQ4KR5QH-wMgTlP7f.js → chunk-NQ4KR5QH-DXUTQ-BL.js} +1 -1
  29. package/dist/web/assets/{chunk-O4XLMI2P-JC6EGoUz.js → chunk-O4XLMI2P-BsUWb9d0.js} +1 -1
  30. package/dist/web/assets/{chunk-OZEHJAEY-BXhYx3nO.js → chunk-OZEHJAEY-rG0P22U9.js} +1 -1
  31. package/dist/web/assets/{chunk-PQ6SQG4A-D6BTbCQw.js → chunk-PQ6SQG4A-DX0xW7kO.js} +1 -1
  32. package/dist/web/assets/{chunk-PU5JKC2W-Dw8ClWch.js → chunk-PU5JKC2W-C7Gry6md.js} +1 -1
  33. package/dist/web/assets/chunk-QZHKN3VN-DFKFM_C1.js +1 -0
  34. package/dist/web/assets/{chunk-R5LLSJPH-CFwSJijQ.js → chunk-R5LLSJPH-CMY0PkRK.js} +1 -1
  35. package/dist/web/assets/{chunk-WL4C6EOR-DfofndiH.js → chunk-WL4C6EOR-CXuQvlyu.js} +1 -1
  36. package/dist/web/assets/{chunk-XIRO2GV7-Djlmrely.js → chunk-XIRO2GV7-DRJEb7Zb.js} +1 -1
  37. package/dist/web/assets/{chunk-XPW4576I-BPQQBakK.js → chunk-XPW4576I-BPEX8KhL.js} +1 -1
  38. package/dist/web/assets/{chunk-XZSTWKYB-DxAOx4hG.js → chunk-XZSTWKYB-Cb0iqycX.js} +1 -1
  39. package/dist/web/assets/{chunk-YBOYWFTD-CeU4Q-xC.js → chunk-YBOYWFTD-av5aeHLq.js} +1 -1
  40. package/dist/web/assets/classDiagram-VBA2DB6C-Dp4Kk3Yb.js +1 -0
  41. package/dist/web/assets/classDiagram-v2-RAHNMMFH-D8IvcV_B.js +1 -0
  42. package/dist/web/assets/clone-B2hUek6n.js +1 -0
  43. package/dist/web/assets/code-editor-DLTcPb55.js +2 -0
  44. package/dist/web/assets/{cose-bilkent-S5V4N54A-B_AWZsOP.js → cose-bilkent-S5V4N54A-qudEiMCT.js} +1 -1
  45. package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-DUbHtTAS.js} +1 -1
  46. package/dist/web/assets/{dagre-Dbb5k38K.js → dagre-BFcnKyBF.js} +1 -1
  47. package/dist/web/assets/{dagre-KLK3FWXG-BH7aWGRP.js → dagre-KLK3FWXG-C3O-MTLf.js} +1 -1
  48. package/dist/web/assets/{database-viewer-DXk79Nel.js → database-viewer-BrpPlYG7.js} +1 -1
  49. package/dist/web/assets/{diagram-E7M64L7V-B1Qz70Do.js → diagram-E7M64L7V-DxPjK7_c.js} +1 -1
  50. package/dist/web/assets/{diagram-IFDJBPK2-k55eVqVU.js → diagram-IFDJBPK2-sqTog_XV.js} +1 -1
  51. package/dist/web/assets/{diagram-P4PSJMXO-BkfNRc9U.js → diagram-P4PSJMXO-hzmp0GHK.js} +1 -1
  52. package/dist/web/assets/diff-viewer-Dx96kcTu.js +4 -0
  53. package/dist/web/assets/{erDiagram-INFDFZHY-CKzVujYI.js → erDiagram-INFDFZHY-DLeYhAAT.js} +1 -1
  54. package/dist/web/assets/{flowDiagram-PKNHOUZH-DIqcTrDV.js → flowDiagram-PKNHOUZH-CRxlE9Sr.js} +1 -1
  55. package/dist/web/assets/{ganttDiagram-A5KZAMGK-D4v7ZbVE.js → ganttDiagram-A5KZAMGK-BdjmoMLS.js} +1 -1
  56. package/dist/web/assets/git-graph-CoN6voTp.js +1 -0
  57. package/dist/web/assets/gitGraph-HDMCJU4V-CNlas3Rz.js +1 -0
  58. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-BTXo57mF.js → gitGraphDiagram-K3NZZRJ6-BeHSX7kk.js} +1 -1
  59. package/dist/web/assets/{graphlib-BcsNnGcW.js → graphlib-Duh_bWLa.js} +1 -1
  60. package/dist/web/assets/index-CtbNK_ih.css +2 -0
  61. package/dist/web/assets/index-DRdx_Wqn.js +37 -0
  62. package/dist/web/assets/info-3K5VOQVL-BDzTLc11.js +1 -0
  63. package/dist/web/assets/infoDiagram-LFFYTUFH-ZZmpgc6t.js +2 -0
  64. package/dist/web/assets/{isEmpty-bnrF3Qbc.js → isEmpty-B9L-Ge-H.js} +1 -1
  65. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-BOyvKMmB.js → ishikawaDiagram-PHBUUO56-Cu0Rt1Ok.js} +1 -1
  66. package/dist/web/assets/{journeyDiagram-4ABVD52K-ufoasAy6.js → journeyDiagram-4ABVD52K-CgDI-UG4.js} +1 -1
  67. package/dist/web/assets/{kanban-definition-K7BYSVSG-Bi0UTUeN.js → kanban-definition-K7BYSVSG-h4g10UHL.js} +1 -1
  68. package/dist/web/assets/keybindings-store-DHGoLYnP.js +1 -0
  69. package/dist/web/assets/{line-B78g-52T.js → line-B75-Rx70.js} +1 -1
  70. package/dist/web/assets/{linear-DP4mkX3m.js → linear-Bcjv9FQt.js} +1 -1
  71. package/dist/web/assets/{markdown-renderer-Brj8_LQM.js → markdown-renderer-BqsXIW9n.js} +5 -5
  72. package/dist/web/assets/{mermaid-parser.core-DMIWdgEW.js → mermaid-parser.core-8u2leTXI.js} +2 -2
  73. package/dist/web/assets/{mindmap-definition-YRQLILUH-BsfWvIoO.js → mindmap-definition-YRQLILUH-BaOBwb-W.js} +1 -1
  74. package/dist/web/assets/{ordinal-_K3x1fkz.js → ordinal-LFEjVtwQ.js} +1 -1
  75. package/dist/web/assets/packet-RMMSAZCW-IVa5F-go.js +1 -0
  76. package/dist/web/assets/pie-UPGHQEXC-CvXHKAzp.js +1 -0
  77. package/dist/web/assets/{pieDiagram-SKSYHLDU-WP0XXw51.js → pieDiagram-SKSYHLDU-At5Kz0KK.js} +1 -1
  78. package/dist/web/assets/{postgres-viewer-CwkTGmqy.js → postgres-viewer-Lw8xaGfc.js} +1 -1
  79. package/dist/web/assets/{quadrantDiagram-337W2JSQ-FHMogtsh.js → quadrantDiagram-337W2JSQ-CdjGIDfw.js} +1 -1
  80. package/dist/web/assets/radar-KQ55EAFF-Z-Tr5wtS.js +1 -0
  81. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-BatTxyWb.js → requirementDiagram-Z7DCOOCP-B9F_Cx_p.js} +1 -1
  82. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-ClJuW3Hv.js → sankeyDiagram-WA2Y5GQK-RolPi8bU.js} +1 -1
  83. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-ByxQqGgs.js → sequenceDiagram-2WXFIKYE-DM-tMAhx.js} +1 -1
  84. package/dist/web/assets/settings-tab-DDCC58we.js +1 -0
  85. package/dist/web/assets/{sqlite-viewer-CFYTwgA8.js → sqlite-viewer-DECA802J.js} +1 -1
  86. package/dist/web/assets/{stateDiagram-RAJIS63D-f8opcZNY.js → stateDiagram-RAJIS63D-C4EMl6jf.js} +1 -1
  87. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-B-UjZch3.js +1 -0
  88. package/dist/web/assets/{tab-store-BJw7OCmy.js → tab-store--SlERlDs.js} +1 -1
  89. package/dist/web/assets/{terminal-tab-CCDLZA5Y.js → terminal-tab-DneNM6WP.js} +2 -2
  90. package/dist/web/assets/{timeline-definition-YZTLITO2-58BlOSf9.js → timeline-definition-YZTLITO2-A4PN_Efm.js} +1 -1
  91. package/dist/web/assets/treemap-KZPCXAKY-C9TYRE0k.js +1 -0
  92. package/dist/web/assets/{use-monaco-theme-CNzekTN3.js → use-monaco-theme-CrtYAJMR.js} +1 -1
  93. package/dist/web/assets/{vennDiagram-LZ73GAT5-BOSy9ma9.js → vennDiagram-LZ73GAT5-ywK7LMaH.js} +1 -1
  94. package/dist/web/assets/{xychartDiagram-JWTSCODW-z5MVJauZ.js → xychartDiagram-JWTSCODW-DylHYNtJ.js} +1 -1
  95. package/dist/web/index.html +10 -11
  96. package/dist/web/sw.js +1 -1
  97. package/docs/code-standards.md +155 -0
  98. package/docs/codebase-summary.md +261 -95
  99. package/docs/project-changelog.md +38 -3
  100. package/docs/project-roadmap.md +2 -2
  101. package/docs/streaming-input-guide.md +267 -0
  102. package/docs/system-architecture.md +151 -0
  103. package/package.json +1 -1
  104. package/snapshot-state.md +1526 -0
  105. package/src/providers/claude-agent-sdk.ts +244 -102
  106. package/src/providers/cli-provider-base.ts +238 -0
  107. package/src/providers/cursor-cli/cursor-event-mapper.ts +85 -0
  108. package/src/providers/cursor-cli/cursor-history.ts +207 -0
  109. package/src/providers/cursor-cli/cursor-provider.ts +146 -0
  110. package/src/providers/mock-provider.ts +1 -1
  111. package/src/providers/provider.interface.ts +1 -0
  112. package/src/providers/registry.ts +43 -4
  113. package/src/server/index.ts +6 -0
  114. package/src/server/routes/chat.ts +14 -3
  115. package/src/server/routes/mcp.ts +84 -0
  116. package/src/server/routes/settings.ts +14 -0
  117. package/src/server/ws/chat.ts +127 -81
  118. package/src/services/account.service.ts +2 -2
  119. package/src/services/chat.service.ts +10 -15
  120. package/src/services/claude-usage.service.ts +2 -7
  121. package/src/services/db.service.ts +8 -0
  122. package/src/services/mcp-config.service.ts +111 -0
  123. package/src/types/api.ts +1 -1
  124. package/src/types/chat.ts +23 -2
  125. package/src/types/config.ts +33 -11
  126. package/src/types/mcp.ts +47 -0
  127. package/src/utils/ndjson-line-parser.ts +36 -0
  128. package/src/web/components/chat/chat-history-bar.tsx +48 -29
  129. package/src/web/components/chat/chat-tab.tsx +29 -24
  130. package/src/web/components/chat/message-input.tsx +64 -5
  131. package/src/web/components/chat/provider-selector.tsx +150 -0
  132. package/src/web/components/chat/session-picker.tsx +3 -1
  133. package/src/web/components/chat/usage-badge.tsx +58 -8
  134. package/src/web/components/settings/ai-settings-section.tsx +196 -137
  135. package/src/web/components/settings/mcp-server-dialog.tsx +208 -0
  136. package/src/web/components/settings/mcp-settings-section.tsx +143 -0
  137. package/src/web/components/settings/settings-tab.tsx +5 -2
  138. package/src/web/hooks/use-chat.ts +32 -15
  139. package/src/web/lib/api-mcp.ts +38 -0
  140. package/test-tokens.mjs +212 -0
  141. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  142. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  143. package/dist/web/assets/api-settings-Bx1GaNmQ.js +0 -1
  144. package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +0 -1
  145. package/dist/web/assets/channel-wrd-NHWf.js +0 -1
  146. package/dist/web/assets/chat-tab-BDYE0KHF.js +0 -8
  147. package/dist/web/assets/chunk-GLR3WWYH-CzYx4w-r.js +0 -2
  148. package/dist/web/assets/chunk-HHEYEP7N-HRhYy3kG.js +0 -1
  149. package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +0 -1
  150. package/dist/web/assets/classDiagram-VBA2DB6C-lse8oZoJ.js +0 -1
  151. package/dist/web/assets/classDiagram-v2-RAHNMMFH-CxkwuInd.js +0 -1
  152. package/dist/web/assets/clone-LRxlvnMj.js +0 -1
  153. package/dist/web/assets/code-editor-DTA3c9Y8.js +0 -2
  154. package/dist/web/assets/diff-viewer-HhIcsOQE.js +0 -4
  155. package/dist/web/assets/git-graph-CQtWu8yE.js +0 -1
  156. package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +0 -1
  157. package/dist/web/assets/index-CgQXpBb_.css +0 -2
  158. package/dist/web/assets/index-DEeeRoka.js +0 -37
  159. package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +0 -1
  160. package/dist/web/assets/infoDiagram-LFFYTUFH-B1CX0pbC.js +0 -2
  161. package/dist/web/assets/input-BglMT33g.js +0 -1
  162. package/dist/web/assets/keybindings-store-1CJ7VX57.js +0 -1
  163. package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +0 -1
  164. package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +0 -1
  165. package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +0 -1
  166. package/dist/web/assets/settings-tab-BDE1MsIh.js +0 -1
  167. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-DrxVDY9q.js +0 -1
  168. package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +0 -1
  169. /package/dist/web/assets/{api-client-BfBM3I7n.js → api-client-BKIT_Qeg.js} +0 -0
  170. /package/dist/web/assets/{array-B9UHiPd-.js → array-DqLCdDFv.js} +0 -0
  171. /package/dist/web/assets/{chevron-right-DeV0ehiG.js → chevron-right-CHnjJt4E.js} +0 -0
  172. /package/dist/web/assets/{columns-2-DpsNbZOc.js → columns-2-DbesTfa7.js} +0 -0
  173. /package/dist/web/assets/{cytoscape.esm-BW-DbntU.js → cytoscape.esm-CWPXKqbJ.js} +0 -0
  174. /package/dist/web/assets/{defaultLocale-5eAKkKJC.js → defaultLocale-CrJzLgRD.js} +0 -0
  175. /package/dist/web/assets/{dist-lF8CoYII.js → dist-CALwEtco.js} +0 -0
  176. /package/dist/web/assets/{dist-CSJdAyA9.js → dist-Cep75xXf.js} +0 -0
  177. /package/dist/web/assets/{dist-DylI9XxN.js → dist-DGDPTxs1.js} +0 -0
  178. /package/dist/web/assets/{init-DlZdxViB.js → init-C0r9Gk5G.js} +0 -0
  179. /package/dist/web/assets/{isArrayLikeObject-B_v2FtYn.js → isArrayLikeObject-CGBoxvCD.js} +0 -0
  180. /package/dist/web/assets/{katex-Bqvo_ZG0.js → katex-DzXRfQ_m.js} +0 -0
  181. /package/dist/web/assets/{lib-BQ34Db2e.js → lib-BeaDXEkP.js} +0 -0
  182. /package/dist/web/assets/{math-069Z4SuC.js → math-y9zN1W-N.js} +0 -0
  183. /package/dist/web/assets/{path-6uRLdFF7.js → path-DIKpVbHL.js} +0 -0
  184. /package/dist/web/assets/{preload-helper-uTix4PVD.js → preload-helper-Bf_JiD2A.js} +0 -0
  185. /package/dist/web/assets/{react-ER-4DN55.js → react-SKk5z-bm.js} +0 -0
  186. /package/dist/web/assets/{rough.esm-JX0wREDd.js → rough.esm-nHaDi0Kw.js} +0 -0
  187. /package/dist/web/assets/{src-BqX54PbV.js → src-Dw4QhedI.js} +0 -0
  188. /package/dist/web/assets/{table-C7X5UAEI.js → table-CQVQM2SB.js} +0 -0
  189. /package/dist/web/assets/{tag-CCtdV063.js → tag-Q2dZiSPX.js} +0 -0
  190. /package/dist/web/assets/{utils-BNytJOb1.js → utils-DMiycH3O.js} +0 -0
@@ -19,11 +19,17 @@ class ProviderRegistry {
19
19
  return this.providers.get(id);
20
20
  }
21
21
 
22
+ /** List providers visible to users (excludes internal-only providers like mock) */
22
23
  list(): ProviderInfo[] {
23
- return Array.from(this.providers.values()).map((p) => ({
24
- id: p.id,
25
- name: p.name,
26
- }));
24
+ return Array.from(this.providers.values())
25
+ .filter((p) => p.id !== "mock")
26
+ .map((p) => ({ id: p.id, name: p.name }));
27
+ }
28
+
29
+ /** List all registered providers including internal ones (for ChatService aggregation) */
30
+ listAll(): ProviderInfo[] {
31
+ return Array.from(this.providers.values())
32
+ .map((p) => ({ id: p.id, name: p.name }));
27
33
  }
28
34
 
29
35
  /** Get the default provider based on config's default_provider */
@@ -40,5 +46,38 @@ class ProviderRegistry {
40
46
 
41
47
  /** Singleton registry */
42
48
  export const providerRegistry = new ProviderRegistry();
49
+
50
+ // SDK providers registered synchronously (no binary check needed)
43
51
  providerRegistry.register(new ClaudeAgentSdkProvider());
44
52
  providerRegistry.register(new MockProvider()); // testing only
53
+
54
+ /**
55
+ * Bootstrap CLI providers asynchronously.
56
+ * Checks isAvailable() before registering — call at server startup.
57
+ */
58
+ export async function bootstrapProviders(): Promise<void> {
59
+ try {
60
+ const { CursorCliProvider } = await import("./cursor-cli/cursor-provider.ts");
61
+ const cursor = new CursorCliProvider();
62
+ if (await cursor.isAvailable()) {
63
+ providerRegistry.register(cursor);
64
+ // Ensure config has an entry for cursor so settings UI shows it
65
+ const ai = configService.get("ai");
66
+ if (!ai.providers["cursor"]) {
67
+ configService.set("ai", {
68
+ ...ai,
69
+ providers: {
70
+ ...ai.providers,
71
+ cursor: { type: "cli", cli_command: "cursor-agent", permission_mode: "bypassPermissions" },
72
+ },
73
+ });
74
+ configService.save();
75
+ }
76
+ console.log("[registry] Cursor provider registered (cursor-agent found)");
77
+ } else {
78
+ console.log("[registry] Cursor provider skipped (cursor-agent not found)");
79
+ }
80
+ } catch (e) {
81
+ console.warn("[registry] Failed to load Cursor provider:", (e as Error).message);
82
+ }
83
+ }
@@ -14,6 +14,7 @@ import { databaseRoutes } from "./routes/database.ts";
14
14
  import { fsBrowseRoutes } from "./routes/fs-browse.ts";
15
15
  import { accountsRoutes } from "./routes/accounts.ts";
16
16
  import { proxyRoutes } from "./routes/proxy.ts";
17
+ import { mcpRoutes } from "./routes/mcp.ts";
17
18
  import { browserPreviewRoutes } from "./routes/browser-preview.ts";
18
19
  import { initAdapters } from "../services/database/init-adapters.ts";
19
20
  import { terminalWebSocket } from "./ws/terminal.ts";
@@ -139,6 +140,7 @@ app.route("/api/fs", fsBrowseRoutes);
139
140
 
140
141
  // API routes
141
142
  app.route("/api/settings", settingsRoutes);
143
+ app.route("/api/settings/mcp", mcpRoutes);
142
144
  app.route("/api/tunnel", tunnelRoutes);
143
145
  app.route("/api/push", pushRoutes);
144
146
  app.route("/api/projects", projectRoutes);
@@ -174,6 +176,10 @@ export async function startServer(options: {
174
176
 
175
177
  await setupLogFile();
176
178
 
179
+ // Bootstrap CLI providers (checks binary availability)
180
+ const { bootstrapProviders } = await import("../providers/registry.ts");
181
+ await bootstrapProviders();
182
+
177
183
  // Check if port is already in use before spawning supervisor
178
184
  const portInUse = await new Promise<boolean>((resolve) => {
179
185
  const net = require("node:net") as typeof import("node:net");
@@ -57,6 +57,19 @@ chatRoutes.get("/providers", (c) => {
57
57
  }
58
58
  });
59
59
 
60
+ /** GET /chat/providers/:providerId/models — list available models for a provider */
61
+ chatRoutes.get("/providers/:providerId/models", async (c) => {
62
+ try {
63
+ const providerId = c.req.param("providerId");
64
+ const provider = providerRegistry.get(providerId);
65
+ if (!provider) return c.json(err(`Provider "${providerId}" not found`), 404);
66
+ const models = await provider.listModels?.() ?? [];
67
+ return c.json(ok(models));
68
+ } catch (e) {
69
+ return c.json(err((e as Error).message), 500);
70
+ }
71
+ });
72
+
60
73
  /** GET /chat/sessions — list chat sessions filtered by project from context */
61
74
  chatRoutes.get("/sessions", async (c) => {
62
75
  try {
@@ -179,9 +192,7 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
179
192
  });
180
193
  // Store fork source so WS handler knows to use forkSession on first message
181
194
  const provider = providerRegistry.get(providerId);
182
- if (provider && "setForkSource" in provider) {
183
- (provider as any).setForkSource(session.id, sourceId);
184
- }
195
+ provider?.setForkSource?.(session.id, sourceId);
185
196
  return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
186
197
  } catch (e) {
187
198
  return c.json(err((e as Error).message), 500);
@@ -0,0 +1,84 @@
1
+ import { Hono } from "hono";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { mcpConfigService } from "../../services/mcp-config.service";
6
+ import { validateMcpName, validateMcpConfig, type McpServerConfig } from "../../types/mcp";
7
+ import { ok, err } from "../../types/api";
8
+
9
+ export const mcpRoutes = new Hono();
10
+
11
+ const CLAUDE_CONFIG = join(homedir(), ".claude.json");
12
+
13
+ function readClaudeMcpServers(): Record<string, unknown> | null {
14
+ if (!existsSync(CLAUDE_CONFIG)) return null;
15
+ try {
16
+ const data = JSON.parse(readFileSync(CLAUDE_CONFIG, "utf-8"));
17
+ return data.mcpServers ?? null;
18
+ } catch { return null; }
19
+ }
20
+
21
+ // GET / — list all (auto-imports from ~/.claude.json on first access if table empty)
22
+ mcpRoutes.get("/", (c) => {
23
+ let servers = mcpConfigService.listWithMeta();
24
+ if (servers.length === 0) {
25
+ const claudeServers = readClaudeMcpServers();
26
+ if (claudeServers && Object.keys(claudeServers).length > 0) {
27
+ mcpConfigService.bulkImport(claudeServers as Record<string, McpServerConfig>);
28
+ servers = mcpConfigService.listWithMeta();
29
+ }
30
+ }
31
+ return c.json(ok(servers));
32
+ });
33
+
34
+ // GET /import/preview — show what would be imported
35
+ mcpRoutes.get("/import/preview", (c) => {
36
+ const servers = readClaudeMcpServers();
37
+ if (!servers) return c.json(ok({ available: false, servers: {} }));
38
+ return c.json(ok({ available: true, servers }));
39
+ });
40
+
41
+ // POST /import — import from ~/.claude.json
42
+ mcpRoutes.post("/import", (c) => {
43
+ const servers = readClaudeMcpServers();
44
+ if (!servers) return c.json(err("~/.claude.json not found or has no mcpServers"), 404);
45
+ const result = mcpConfigService.bulkImport(servers as Record<string, McpServerConfig>);
46
+ return c.json(ok(result));
47
+ });
48
+
49
+ // GET /:name — single server
50
+ mcpRoutes.get("/:name", (c) => {
51
+ const config = mcpConfigService.get(c.req.param("name"));
52
+ if (!config) return c.json(err("Server not found"), 404);
53
+ return c.json(ok(config));
54
+ });
55
+
56
+ // POST / — add new server
57
+ mcpRoutes.post("/", async (c) => {
58
+ const { name, config } = await c.req.json();
59
+ const nameErr = validateMcpName(name);
60
+ if (nameErr) return c.json(err(nameErr), 400);
61
+ const configErrs = validateMcpConfig(config);
62
+ if (configErrs.length) return c.json(err(configErrs.join("; ")), 400);
63
+ if (mcpConfigService.exists(name)) return c.json(err("Server already exists"), 409);
64
+ mcpConfigService.set(name, config);
65
+ return c.json(ok({ name }), 201);
66
+ });
67
+
68
+ // PUT /:name — update server config
69
+ mcpRoutes.put("/:name", async (c) => {
70
+ const name = c.req.param("name");
71
+ if (!mcpConfigService.exists(name)) return c.json(err("Server not found"), 404);
72
+ const config = await c.req.json();
73
+ const configErrs = validateMcpConfig(config);
74
+ if (configErrs.length) return c.json(err(configErrs.join("; ")), 400);
75
+ mcpConfigService.set(name, config);
76
+ return c.json(ok({ name }));
77
+ });
78
+
79
+ // DELETE /:name — remove server
80
+ mcpRoutes.delete("/:name", (c) => {
81
+ const removed = mcpConfigService.remove(c.req.param("name"));
82
+ if (!removed) return c.json(err("Server not found"), 404);
83
+ return c.json(ok(true));
84
+ });
@@ -11,6 +11,7 @@ import {
11
11
  } from "../../types/config.ts";
12
12
  import { ok, err } from "../../types/api.ts";
13
13
  import { proxyService } from "../../services/proxy.service.ts";
14
+ import { providerRegistry } from "../../providers/registry.ts";
14
15
 
15
16
  export const settingsRoutes = new Hono();
16
17
 
@@ -155,6 +156,19 @@ settingsRoutes.put("/ai", async (c) => {
155
156
  }
156
157
  });
157
158
 
159
+ /** GET /settings/ai/providers/:id/models — list models for a provider (global, no project context needed) */
160
+ settingsRoutes.get("/ai/providers/:id/models", async (c) => {
161
+ try {
162
+ const id = c.req.param("id");
163
+ const provider = providerRegistry.get(id);
164
+ if (!provider) return c.json(err(`Provider "${id}" not found`), 404);
165
+ const models = await provider.listModels?.() ?? [];
166
+ return c.json(ok(models));
167
+ } catch (e) {
168
+ return c.json(err((e as Error).message), 500);
169
+ }
170
+ });
171
+
158
172
  // ── Keybindings ──────────────────────────────────────────────────────
159
173
 
160
174
  const KEYBINDINGS_KEY = "keybindings";
@@ -23,7 +23,6 @@ type ChatWsSocket = {
23
23
  interface SessionEntry {
24
24
  providerId: string;
25
25
  clients: Set<ChatWsSocket>;
26
- abort?: AbortController;
27
26
  projectPath?: string;
28
27
  projectName?: string;
29
28
  pingIntervals: Map<ChatWsSocket, ReturnType<typeof setInterval>>;
@@ -33,6 +32,8 @@ interface SessionEntry {
33
32
  turnEvents: unknown[];
34
33
  streamPromise?: Promise<void>;
35
34
  permissionMode?: string;
35
+ /** Whether the persistent event consumer loop is running */
36
+ isStreamingActive: boolean;
36
37
  }
37
38
 
38
39
  /** Tracks active sessions — persists even when FE disconnects */
@@ -118,42 +119,46 @@ function clearClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
118
119
  }
119
120
  }
120
121
 
121
- /** Start cleanup timer — only called when Claude is done AND no FE connected */
122
- function startCleanupTimer(sessionId: string): void {
122
+ /** Start cleanup timer — called when no FE connected. Urgent mode (30s) for orphaned streaming sessions. */
123
+ function startCleanupTimer(sessionId: string, urgent = false): void {
123
124
  const entry = activeSessions.get(sessionId);
124
125
  if (!entry) return;
125
126
  if (entry.cleanupTimer) clearTimeout(entry.cleanupTimer);
127
+ const delay = urgent ? 30_000 : CLEANUP_TIMEOUT_MS;
126
128
  entry.cleanupTimer = setTimeout(() => {
127
129
  console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
128
130
  logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
131
+ // Close streaming session in provider
132
+ const provider = providerRegistry.get(entry.providerId);
133
+ if (provider && "closeStreamingSession" in provider) {
134
+ (provider as any).closeStreamingSession(sessionId);
135
+ }
129
136
  for (const interval of entry.pingIntervals.values()) clearInterval(interval);
130
137
  entry.pingIntervals.clear();
131
138
  activeSessions.delete(sessionId);
132
- }, CLEANUP_TIMEOUT_MS);
139
+ }, delay);
133
140
  }
134
141
 
135
142
  /**
136
- * Standalone streaming loopdecoupled from WS message handler.
137
- * Runs independently so WS close does NOT kill the Claude query.
143
+ * Persistent event consumerruns for the entire session lifetime.
144
+ * First message creates the query; follow-ups push into the provider's
145
+ * message channel. Events from ALL turns flow through this single loop.
138
146
  */
139
- async function runStreamLoop(sessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
147
+ async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>): Promise<void> {
140
148
  const entry = activeSessions.get(sessionId);
141
149
  if (!entry) {
142
- console.error(`[chat] session=${sessionId} runStreamLoop: no entry — aborting`);
150
+ console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
143
151
  return;
144
152
  }
145
- const streamStartMs = Date.now();
146
- console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
153
+ console.log(`[chat] session=${sessionId} startSessionConsumer started (clients=${entry.clients.size})`);
147
154
 
148
- const abortController = new AbortController();
149
- entry.abort = abortController;
155
+ entry.isStreamingActive = true;
150
156
  entry.pendingApprovalEvent = undefined;
151
157
  entry.turnEvents = [];
152
158
  setPhase(sessionId, "connecting");
153
159
 
154
160
  let heartbeat: ReturnType<typeof setInterval> | undefined;
155
161
  let lastContextWindowPct: number | undefined;
156
- let doneEmitted = false;
157
162
 
158
163
  try {
159
164
  const userPreview = content.slice(0, 200);
@@ -162,12 +167,12 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
162
167
 
163
168
  let eventCount = 0;
164
169
  let firstEventReceived = false;
165
- const startTime = Date.now();
170
+ let startTime = Date.now();
166
171
 
167
172
  // Heartbeat: while waiting for first response, send elapsed time every 5s
168
173
  const CONNECTION_TIMEOUT_S = 120;
169
174
  heartbeat = setInterval(() => {
170
- if (firstEventReceived || abortController.signal.aborted) {
175
+ if (firstEventReceived) {
171
176
  clearInterval(heartbeat);
172
177
  return;
173
178
  }
@@ -186,27 +191,40 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
186
191
  type: "error",
187
192
  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
193
  });
189
- abortController.abort();
190
194
  return;
191
195
  }
192
- // Heartbeat uses broadcast() directly — NOT setPhase() (same-phase guard would skip elapsed updates)
193
196
  broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
194
197
  }, 5_000);
195
198
 
196
- for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
197
- if (abortController.signal.aborted) break;
199
+ for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
198
200
  eventCount++;
199
201
  const ev = event as any;
200
202
  const evType = ev.type ?? "unknown";
201
203
 
202
- // System events (hook_started, init, etc.) transition connecting thinking
203
- // These indicate SDK has connected and is processing, but no content yet.
204
+ // Session ID migrated: CLI provider assigned a different ID than PPM generated.
205
+ // Migrate activeSessions key so all subsequent events use the real ID.
206
+ if (evType === "session_migrated") {
207
+ const { oldSessionId, newSessionId } = ev;
208
+ const migrated = activeSessions.get(oldSessionId);
209
+ if (migrated) {
210
+ activeSessions.delete(oldSessionId);
211
+ activeSessions.set(newSessionId, migrated);
212
+ sessionId = newSessionId; // update local ref for subsequent setPhase/broadcast calls
213
+ // Notify frontend to update its sessionId state
214
+ broadcast(newSessionId, { type: "session_migrated", oldSessionId, newSessionId });
215
+ console.log(`[chat] session migrated: ${oldSessionId} → ${newSessionId}`);
216
+ logSessionEvent(newSessionId, "INFO", `Session ID migrated from ${oldSessionId}`);
217
+ }
218
+ continue;
219
+ }
220
+
221
+ // System events → transition connecting → thinking
204
222
  if (evType === "system") {
205
223
  if (!firstEventReceived) {
206
224
  if (heartbeat) clearInterval(heartbeat);
207
225
  setPhase(sessionId, "thinking");
208
226
  }
209
- continue; // Don't buffer or broadcast system events
227
+ continue;
210
228
  }
211
229
 
212
230
  // First content event — stop heartbeat, transition phase
@@ -239,10 +257,11 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
239
257
  console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
240
258
  logSessionEvent(sessionId, "ERROR", errorDetail);
241
259
  } else if (evType === "done") {
242
- doneEmitted = true;
260
+ // Turn complete — transition to idle, clear buffer for next turn
243
261
  logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
244
262
  if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
245
- // Fire-and-forget: fetch updated session title (DB title takes priority)
263
+
264
+ // Fire-and-forget: fetch updated session title (DB title takes priority) + notification
246
265
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
247
266
  const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
248
267
  const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
@@ -253,7 +272,6 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
253
272
  if (session) session.title = title;
254
273
  }
255
274
  }).catch(() => {});
256
- // Fire-and-forget notification broadcast (push + telegram)
257
275
  import("../../services/notification.service.ts").then(({ notificationService }) => {
258
276
  const project = entry.projectName || "Project";
259
277
  const session = chatService.getSession(sessionId);
@@ -268,7 +286,6 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
268
286
  }).catch(() => {});
269
287
  } else if (evType === "approval_request") {
270
288
  entry.pendingApprovalEvent = ev;
271
- // Fire-and-forget notification for approval/question
272
289
  import("../../services/notification.service.ts").then(({ notificationService }) => {
273
290
  const project = entry.projectName || "Project";
274
291
  const session = chatService.getSession(sessionId);
@@ -287,32 +304,40 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
287
304
 
288
305
  // Buffer + broadcast content events
289
306
  bufferAndBroadcast(sessionId, event);
307
+
308
+ // After "done", transition to idle + clear turn buffer for next turn
309
+ // Consumer loop continues — query waits for next message in generator
310
+ if (evType === "done") {
311
+ entry.turnEvents = [];
312
+ entry.pendingApprovalEvent = undefined;
313
+ setPhase(sessionId, "idle");
314
+ // Reset heartbeat tracking for next turn
315
+ firstEventReceived = false;
316
+ startTime = Date.now();
317
+ }
290
318
  }
291
319
 
292
- logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
293
- console.log(`[chat] session=${sessionId} stream completed (${eventCount} events)`);
320
+ logSessionEvent(sessionId, "INFO", `Session consumer completed (${eventCount} events total)`);
321
+ console.log(`[chat] session=${sessionId} session consumer completed (${eventCount} events)`);
294
322
  } catch (e) {
295
323
  const errMsg = (e as Error).message;
296
324
  logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
297
- if (!abortController.signal.aborted) {
298
- bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
299
- }
325
+ bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
300
326
  } finally {
301
327
  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
328
+ entry.isStreamingActive = false;
307
329
  entry.turnEvents = [];
308
- // 3. Transition to idle
309
330
  setPhase(sessionId, "idle");
310
- // 4. Cleanup
311
- entry.abort = undefined;
312
331
  entry.pendingApprovalEvent = undefined;
332
+ // Close streaming session in provider
333
+ const provider = providerRegistry.get(entry.providerId);
334
+ if (provider && "closeStreamingSession" in provider) {
335
+ (provider as any).closeStreamingSession(sessionId);
336
+ }
313
337
  if (entry.clients.size === 0) {
314
338
  startCleanupTimer(sessionId);
315
339
  }
340
+ console.log(`[chat] session=${sessionId} consumer loop ended`);
316
341
  }
317
342
  }
318
343
 
@@ -389,6 +414,7 @@ export const chatWebSocket = {
389
414
  pingIntervals: new Map(),
390
415
  phase: "idle",
391
416
  turnEvents: [],
417
+ isStreamingActive: false,
392
418
  };
393
419
  activeSessions.set(sessionId, newEntry);
394
420
  setupClientPing(newEntry, ws);
@@ -439,7 +465,7 @@ export const chatWebSocket = {
439
465
  if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
440
466
  const newEntry: SessionEntry = {
441
467
  providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
442
- pingIntervals: new Map(), phase: "idle", turnEvents: [],
468
+ pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
443
469
  };
444
470
  activeSessions.set(sessionId, newEntry);
445
471
  setupClientPing(newEntry, ws);
@@ -476,57 +502,76 @@ export const chatWebSocket = {
476
502
  ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
477
503
  return;
478
504
  }
505
+ // Validate image payload
506
+ if (parsed.images?.length) {
507
+ if (parsed.images.length > 5) {
508
+ ws.send(JSON.stringify({ type: "error", message: "Max 5 images per message" }));
509
+ return;
510
+ }
511
+ const MAX_BASE64_SIZE = 7_000_000; // ~5MB decoded
512
+ const SUPPORTED_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
513
+ for (const img of parsed.images) {
514
+ if (img.data.length > MAX_BASE64_SIZE) {
515
+ ws.send(JSON.stringify({ type: "error", message: "Image too large (max 5MB)" }));
516
+ return;
517
+ }
518
+ if (!SUPPORTED_TYPES.has(img.mediaType)) {
519
+ ws.send(JSON.stringify({ type: "error", message: `Unsupported image type: ${img.mediaType}` }));
520
+ return;
521
+ }
522
+ }
523
+ }
479
524
  // Store permission mode — sticky for this session
480
525
  if (parsed.permissionMode) {
481
526
  entry.permissionMode = parsed.permissionMode;
482
527
  }
483
528
 
484
- // Resume session in provider (can be slow on first call — sdkListSessions)
485
529
  const provider = providerRegistry.get(providerId);
486
- if (provider && "resumeSession" in provider) {
487
- const t0 = Date.now();
488
- await (provider as any).resumeSession(sessionId);
489
- const elapsed = Date.now() - t0;
490
- if (elapsed > 500) {
491
- console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
492
- logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
530
+
531
+ if (!entry.isStreamingActive) {
532
+ // First message or post-crash recovery: start persistent consumer
533
+ // Resume session in provider (can be slow on first call — sdkListSessions)
534
+ if (provider && "resumeSession" in provider) {
535
+ const t0 = Date.now();
536
+ await (provider as any).resumeSession(sessionId);
537
+ const elapsed = Date.now() - t0;
538
+ if (elapsed > 500) {
539
+ console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
540
+ logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
541
+ }
542
+ }
543
+ if (entry.projectPath && provider && "ensureProjectPath" in provider) {
544
+ (provider as any).ensureProjectPath(sessionId, entry.projectPath);
493
545
  }
494
- }
495
- if (entry.projectPath && provider && "ensureProjectPath" in provider) {
496
- (provider as any).ensureProjectPath(sessionId, entry.projectPath);
497
- }
498
546
 
499
- // Abort-and-replace: if already streaming, abort current query and wait for cleanup
500
- if (entry.phase !== "idle" && entry.abort) {
501
- console.log(`[chat] session=${sessionId} aborting current query for new message`);
502
- entry.abort.abort();
503
- if (entry.streamPromise) {
504
- await entry.streamPromise;
547
+ entry.turnEvents = [];
548
+ setPhase(sessionId, "initializing");
549
+
550
+ const permMode = entry.permissionMode;
551
+ const msgImages = parsed.type === "message" ? parsed.images : undefined;
552
+ entry.streamPromise = new Promise<void>((resolve) => {
553
+ setTimeout(() => {
554
+ startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
555
+ }, 0);
556
+ });
557
+ } else {
558
+ // Follow-up: push into existing generator via provider
559
+ if (provider && "pushMessage" in provider && parsed.type === "message") {
560
+ (provider as any).pushMessage(sessionId, parsed.content, {
561
+ priority: parsed.priority ?? 'next',
562
+ images: parsed.images,
563
+ });
505
564
  }
506
- // Re-fetch entry after await may have been mutated during cleanup
507
- entry = activeSessions.get(sessionId)!;
508
- if (!entry) return;
565
+ // Clear turn events for new turn display + transition phase
566
+ entry.turnEvents = [];
567
+ entry.pendingApprovalEvent = undefined;
568
+ setPhase(sessionId, "thinking");
569
+ console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
509
570
  }
510
-
511
- // Reset for new query
512
- entry.turnEvents = [];
513
- setPhase(sessionId, "initializing");
514
-
515
- // Store promise reference on entry to prevent GC from collecting the async operation.
516
- // Use setTimeout(0) to detach from WS handler's async scope.
517
- const permMode = entry.permissionMode;
518
- entry.streamPromise = new Promise<void>((resolve) => {
519
- setTimeout(() => {
520
- runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
521
- }, 0);
522
- });
523
571
  } else if (parsed.type === "cancel") {
524
- // Signal abortController so runStreamLoop suppresses error broadcast
525
- if (entry?.abort) entry.abort.abort();
572
+ // Fully teardown streaming session user must resume to continue
526
573
  const provider = providerRegistry.get(providerId);
527
- if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
528
- (provider as any).abortQuery(sessionId);
529
- }
574
+ provider?.abortQuery?.(sessionId);
530
575
  } else if (parsed.type === "approval_response") {
531
576
  const provider = providerRegistry.get(providerId);
532
577
  if (provider && typeof provider.resolveApproval === "function") {
@@ -549,8 +594,9 @@ export const chatWebSocket = {
549
594
  evictClient(entry, ws);
550
595
  console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
551
596
 
552
- if (entry.clients.size === 0 && entry.phase === "idle") {
553
- startCleanupTimer(sessionId);
597
+ if (entry.clients.size === 0) {
598
+ // Use shorter timeout if streaming is still active (orphaned session — no FE to consume events)
599
+ startCleanupTimer(sessionId, entry.isStreamingActive);
554
600
  }
555
601
  },
556
602
  };
@@ -139,7 +139,7 @@ class AccountService {
139
139
  await this.refreshAccessToken(id, false);
140
140
  return this.getWithTokens(id);
141
141
  } catch (e) {
142
- console.error(`[accounts] Pre-flight refresh failed for ${id}:`, e);
142
+ console.error(`[accounts] Pre-flight refresh failed for ${id}: ${(e as Error).message ?? e}`);
143
143
  return null;
144
144
  }
145
145
  }
@@ -709,7 +709,7 @@ class AccountService {
709
709
  try {
710
710
  await this.refreshAccessToken(acc.id, false);
711
711
  } catch (e) {
712
- console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
712
+ console.error(`[accounts] Auto-refresh failed for ${acc.id}: ${(e as Error).message ?? e}`);
713
713
  }
714
714
  }
715
715
  };