@hienlh/ppm 0.9.0-beta.3 → 0.9.0-beta.4

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 (186) hide show
  1. package/CHANGELOG.md +9 -44
  2. package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-COwDPZl_.js} +1 -1
  3. package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-DCb0mkTp.js} +1 -1
  4. package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-CuUkz5gb.js} +1 -1
  5. package/dist/web/assets/{arc-C2Qaz-ch.js → arc-D0bJaFyD.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-281eTKQ3.js +1 -0
  7. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-BVEUkQYB.js} +1 -1
  8. package/dist/web/assets/arrow-left-C_j9Ki73.js +1 -0
  9. package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-CU2t4NHJ.js} +1 -1
  10. package/dist/web/assets/browser-tab-BhTdeeZd.js +1 -0
  11. package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW-DzjR91sM.js} +1 -1
  12. package/dist/web/assets/channel-CKNZAqoN.js +1 -0
  13. package/dist/web/assets/chat-tab-ZiiUVOxM.js +7 -0
  14. package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-0YMkpW2S.js} +1 -1
  15. package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-Dp0pTM5r.js} +1 -1
  16. package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CuYKSUgJ.js} +1 -1
  17. package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-DvbvLUIN.js} +2 -2
  18. package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-CcEW1AMZ.js} +1 -1
  19. package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-Cgt-qg75.js} +1 -1
  20. package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-JCLgVcaC.js} +1 -1
  21. package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-B82RP9ow.js} +1 -1
  22. package/dist/web/assets/chunk-GLR3WWYH-Bx2UL5jF.js +2 -0
  23. package/dist/web/assets/chunk-HHEYEP7N-BnRVfNc5.js +1 -0
  24. package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-Pb-JMOgO.js} +1 -1
  25. package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-BRj-ZEvL.js} +1 -1
  26. package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-CBRPKraG.js} +1 -1
  27. package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-DNFj84V6.js} +1 -1
  28. package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-BnPzQK-O.js} +1 -1
  29. package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-BRj25yO7.js} +1 -1
  30. package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-BdXwVXjJ.js} +1 -1
  31. package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-LfXT4p8B.js} +1 -1
  32. package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-EdgQyTqa.js} +1 -1
  33. package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-D3thuSok.js} +1 -1
  34. package/dist/web/assets/chunk-QZHKN3VN-gaBt0Rbd.js +1 -0
  35. package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-LdG7RqsM.js} +1 -1
  36. package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-BHFnnXOt.js} +1 -1
  37. package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-DUmQrLsF.js} +1 -1
  38. package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-CsGTseUr.js} +1 -1
  39. package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-5W2emiq4.js} +1 -1
  40. package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-COdZIaX4.js} +1 -1
  41. package/dist/web/assets/classDiagram-VBA2DB6C-CqaIqYPn.js +1 -0
  42. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bo5WN2ok.js +1 -0
  43. package/dist/web/assets/clone-DNDy9Sms.js +1 -0
  44. package/dist/web/assets/{code-editor-D3VJc1tY.js → code-editor-BRMOypkX.js} +1 -1
  45. package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-C1QJ6GPW.js} +1 -1
  46. package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-CWo8w9wK.js} +1 -1
  47. package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-Br4t5TRV.js} +1 -1
  48. package/dist/web/assets/database-viewer-CEoDpzPz.js +1 -0
  49. package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-CkDC2uAj.js} +1 -1
  50. package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-NvhckwcA.js} +1 -1
  51. package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO--nUaNiyB.js} +1 -1
  52. package/dist/web/assets/{diff-viewer-D5vGZJnH.js → diff-viewer-jDU2bcGj.js} +1 -1
  53. package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-DK4QEZYh.js} +1 -1
  54. package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-B9h_Ba-v.js} +1 -1
  55. package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-BVlftqyZ.js} +1 -1
  56. package/dist/web/assets/git-graph-DMQzw4Sp.js +1 -0
  57. package/dist/web/assets/gitGraph-HDMCJU4V-D5qEPjgs.js +1 -0
  58. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-L7sj3Bs-.js} +1 -1
  59. package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-BbbiUImY.js} +1 -1
  60. package/dist/web/assets/index-B4Iz1Wbi.css +2 -0
  61. package/dist/web/assets/index-QiSWS6f-.js +37 -0
  62. package/dist/web/assets/info-3K5VOQVL-CbpovIYU.js +1 -0
  63. package/dist/web/assets/infoDiagram-LFFYTUFH-DFh9c-S2.js +2 -0
  64. package/dist/web/assets/input-DGlv6gt_.js +41 -0
  65. package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-DXomfd7J.js} +1 -1
  66. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-cW7SMLa_.js} +1 -1
  67. package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-DFQXUZsc.js} +1 -1
  68. package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-BMUhjxqj.js} +1 -1
  69. package/dist/web/assets/keybindings-store-BplH-yiN.js +1 -0
  70. package/dist/web/assets/{line-DBLLF7lH.js → line--xyfYP3x.js} +1 -1
  71. package/dist/web/assets/{linear-BLFWatDe.js → linear-BdqW7iQu.js} +1 -1
  72. package/dist/web/assets/{markdown-renderer-DcGMlbRm.js → markdown-renderer-BCjJbGP8.js} +5 -5
  73. package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-BY8JfkE_.js} +2 -2
  74. package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-DIv-LMXG.js} +1 -1
  75. package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-CIoJK3nc.js} +1 -1
  76. package/dist/web/assets/packet-RMMSAZCW-BbzPU9BK.js +1 -0
  77. package/dist/web/assets/pie-UPGHQEXC-B0h6hM1j.js +1 -0
  78. package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-seSK40d1.js} +1 -1
  79. package/dist/web/assets/postgres-viewer-s0snZ9CL.js +1 -0
  80. package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-BaRFqlsA.js} +1 -1
  81. package/dist/web/assets/radar-KQ55EAFF-CHptMqVT.js +1 -0
  82. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-1WWjMQB_.js} +1 -1
  83. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-DEGGYsk7.js} +1 -1
  84. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-BtRvoUTC.js} +1 -1
  85. package/dist/web/assets/{settings-store-Bbhg_ptG.js → settings-store-D3dJqGhB.js} +2 -2
  86. package/dist/web/assets/settings-tab-2YkgmrY0.js +1 -0
  87. package/dist/web/assets/sqlite-viewer-B5GNwXaG.js +1 -0
  88. package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-C16aO8tn.js} +1 -1
  89. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-D7qSAjnK.js +1 -0
  90. package/dist/web/assets/switch-mjGtIVDJ.js +1 -0
  91. package/dist/web/assets/{tab-store-dpsCvqhH.js → tab-store-DSz5PQI0.js} +1 -1
  92. package/dist/web/assets/{terminal-tab-DHMITI3S.js → terminal-tab-MRg8y1xF.js} +1 -1
  93. package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-DrjxCpEM.js} +1 -1
  94. package/dist/web/assets/treemap-KZPCXAKY-BL9OJq3X.js +1 -0
  95. package/dist/web/assets/{use-monaco-theme-DHbyUrzJ.js → use-monaco-theme-BQzvItNE.js} +1 -1
  96. package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-DfYFnniI.js} +1 -1
  97. package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-BRvXOVlG.js} +1 -1
  98. package/dist/web/index.html +12 -10
  99. package/dist/web/sw.js +1 -1
  100. package/docs/code-standards.md +260 -7
  101. package/docs/codebase-summary.md +255 -95
  102. package/docs/project-changelog.md +88 -1
  103. package/docs/system-architecture.md +177 -12
  104. package/package.json +1 -1
  105. package/src/providers/claude-agent-sdk.ts +85 -212
  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 +8 -0
  114. package/src/server/routes/browser-preview.ts +89 -0
  115. package/src/server/routes/chat.ts +14 -3
  116. package/src/server/routes/settings.ts +14 -0
  117. package/src/server/ws/chat.ts +91 -106
  118. package/src/services/chat.service.ts +10 -15
  119. package/src/types/api.ts +1 -1
  120. package/src/types/chat.ts +21 -4
  121. package/src/types/config.ts +33 -11
  122. package/src/utils/ndjson-line-parser.ts +36 -0
  123. package/src/web/components/browser/browser-tab.tsx +269 -0
  124. package/src/web/components/chat/chat-history-bar.tsx +49 -29
  125. package/src/web/components/chat/chat-tab.tsx +17 -5
  126. package/src/web/components/chat/message-input.tsx +94 -43
  127. package/src/web/components/chat/provider-selector.tsx +150 -0
  128. package/src/web/components/chat/session-picker.tsx +3 -1
  129. package/src/web/components/layout/command-palette.tsx +4 -0
  130. package/src/web/components/layout/editor-panel.tsx +1 -0
  131. package/src/web/components/layout/mobile-nav.tsx +2 -2
  132. package/src/web/components/layout/panel-layout.tsx +17 -1
  133. package/src/web/components/layout/tab-bar.tsx +2 -0
  134. package/src/web/components/layout/tab-content.tsx +5 -0
  135. package/src/web/components/settings/ai-settings-section.tsx +196 -137
  136. package/src/web/hooks/use-chat.ts +20 -21
  137. package/src/web/hooks/use-global-keybindings.ts +7 -0
  138. package/src/web/hooks/use-voice-input.ts +111 -0
  139. package/src/web/stores/keybindings-store.ts +1 -0
  140. package/src/web/stores/panel-store.ts +10 -10
  141. package/src/web/stores/tab-store.ts +2 -1
  142. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
  143. package/dist/web/assets/channel-w7yboq56.js +0 -1
  144. package/dist/web/assets/chat-tab-DxkvWelV.js +0 -7
  145. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
  146. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
  147. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
  148. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
  149. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
  150. package/dist/web/assets/clone-BSi6cgDh.js +0 -1
  151. package/dist/web/assets/database-viewer-qlwORhh0.js +0 -1
  152. package/dist/web/assets/git-graph-B2fHtKEc.js +0 -1
  153. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
  154. package/dist/web/assets/index-BAioKo_2.css +0 -2
  155. package/dist/web/assets/index-Ccq6zi2E.js +0 -37
  156. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
  157. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
  158. package/dist/web/assets/input-Brjz2Vv-.js +0 -41
  159. package/dist/web/assets/keybindings-store-e3pqlQbf.js +0 -1
  160. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
  161. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
  162. package/dist/web/assets/postgres-viewer-CZzbMFtb.js +0 -1
  163. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
  164. package/dist/web/assets/settings-tab-BOmLAhkD.js +0 -1
  165. package/dist/web/assets/sqlite-viewer-CrrzHXqq.js +0 -1
  166. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
  167. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
  168. /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-icCZ-07C.js} +0 -0
  169. /package/dist/web/assets/{array-BGFCBI0e.js → array-CLwNaqU1.js} +0 -0
  170. /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-Bcg3QJBg.js} +0 -0
  171. /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-B-QQuWwK.js} +0 -0
  172. /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-D_VMtRaY.js} +0 -0
  173. /package/dist/web/assets/{dist-T0Vhi0Mh.js → dist-CMmNEgEP.js} +0 -0
  174. /package/dist/web/assets/{dist-Cce3efmT.js → dist-Ckxnw5rl.js} +0 -0
  175. /package/dist/web/assets/{init-B8gtcn7T.js → init-vVpfz1D6.js} +0 -0
  176. /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-DvHDmeBe.js} +0 -0
  177. /package/dist/web/assets/{katex-Bbu770d9.js → katex-C3cZrCvP.js} +0 -0
  178. /package/dist/web/assets/{math-DwgHI-Cu.js → math-a44lmFDa.js} +0 -0
  179. /package/dist/web/assets/{path-DZF-JdEe.js → path-CuyvWNAH.js} +0 -0
  180. /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-CsoeaaUJ.js} +0 -0
  181. /package/dist/web/assets/{react-BGf7KNLk.js → react-BPIfZRKM.js} +0 -0
  182. /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-c4PR5shF.js} +0 -0
  183. /package/dist/web/assets/{src-BoSBNdA_.js → src-CLWraeNW.js} +0 -0
  184. /package/dist/web/assets/{table-Yo02WRH-.js → table-C9jDaRl2.js} +0 -0
  185. /package/dist/web/assets/{tag-CaC1ng2E.js → tag-CENGyt_L.js} +0 -0
  186. /package/dist/web/assets/{utils-btZ8C8-R.js → utils-Bslrbb-G.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 { browserPreviewRoutes } from "./routes/browser-preview.ts";
17
18
  import { initAdapters } from "../services/database/init-adapters.ts";
18
19
  import { terminalWebSocket } from "./ws/terminal.ts";
19
20
  import { chatWebSocket } from "./ws/chat.ts";
@@ -126,6 +127,9 @@ app.route("/proxy", proxyRoutes);
126
127
  app.use("/api/*", authMiddleware);
127
128
  app.get("/api/auth/check", (c) => c.json(ok(true)));
128
129
 
130
+ // Browser preview reverse proxy — proxies to localhost:<port> for iframe embedding
131
+ app.route("/api/preview", browserPreviewRoutes);
132
+
129
133
  // Filesystem operations (browse, list, read, write) — consolidated in fs-browse route
130
134
  app.route("/api/fs", fsBrowseRoutes);
131
135
 
@@ -166,6 +170,10 @@ export async function startServer(options: {
166
170
  // Setup log file (both foreground and daemon modes)
167
171
  await setupLogFile();
168
172
 
173
+ // Bootstrap CLI providers (checks binary availability)
174
+ const { bootstrapProviders } = await import("../providers/registry.ts");
175
+ await bootstrapProviders();
176
+
169
177
  // Check if port is already in use before starting.
170
178
  // Skip in hot-reload mode — Bun.serve() replaces the previous server on the same port,
171
179
  // but a net.createServer() probe would see it as "in use" and exit prematurely.
@@ -0,0 +1,89 @@
1
+ import { Hono } from "hono";
2
+
3
+ /**
4
+ * Browser preview reverse proxy — forwards requests to localhost:<port>.
5
+ * Mounted at /api/preview/:port/* so the frontend iframe can load
6
+ * any localhost dev server through PPM's own origin (avoiding CORS/framing issues).
7
+ */
8
+ export const browserPreviewRoutes = new Hono();
9
+
10
+ /** Only allow proxying to localhost ports (security: prevent SSRF) */
11
+ function isValidPort(port: string): boolean {
12
+ const n = parseInt(port, 10);
13
+ return !isNaN(n) && n >= 1 && n <= 65535;
14
+ }
15
+
16
+ browserPreviewRoutes.all("/:port{[0-9]+}/*", async (c) => {
17
+ const port = c.req.param("port");
18
+ if (!isValidPort(port)) {
19
+ return c.text("Invalid port", 400);
20
+ }
21
+
22
+ // Build target URL — strip the /api/preview/:port prefix
23
+ const url = new URL(c.req.url);
24
+ const prefix = `/api/preview/${port}`;
25
+ const targetPath = url.pathname.slice(prefix.length) || "/";
26
+ const targetUrl = `http://localhost:${port}${targetPath}${url.search}`;
27
+
28
+ try {
29
+ // Forward the request with original method, headers, and body
30
+ const headers = new Headers(c.req.raw.headers);
31
+ // Remove host header so target server sees localhost
32
+ headers.delete("host");
33
+
34
+ const resp = await fetch(targetUrl, {
35
+ method: c.req.method,
36
+ headers,
37
+ body: ["GET", "HEAD"].includes(c.req.method) ? undefined : c.req.raw.body,
38
+ redirect: "manual",
39
+ });
40
+
41
+ // Clone response headers, remove framing restrictions so iframe works
42
+ const respHeaders = new Headers(resp.headers);
43
+ respHeaders.delete("x-frame-options");
44
+ respHeaders.delete("content-security-policy");
45
+
46
+ return new Response(resp.body, {
47
+ status: resp.status,
48
+ statusText: resp.statusText,
49
+ headers: respHeaders,
50
+ });
51
+ } catch {
52
+ return c.text(`Cannot connect to localhost:${port}`, 502);
53
+ }
54
+ });
55
+
56
+ // Handle root path (no trailing slash)
57
+ browserPreviewRoutes.all("/:port{[0-9]+}", async (c) => {
58
+ const port = c.req.param("port");
59
+ if (!isValidPort(port)) {
60
+ return c.text("Invalid port", 400);
61
+ }
62
+
63
+ const url = new URL(c.req.url);
64
+ const targetUrl = `http://localhost:${port}/${url.search}`;
65
+
66
+ try {
67
+ const headers = new Headers(c.req.raw.headers);
68
+ headers.delete("host");
69
+
70
+ const resp = await fetch(targetUrl, {
71
+ method: c.req.method,
72
+ headers,
73
+ body: ["GET", "HEAD"].includes(c.req.method) ? undefined : c.req.raw.body,
74
+ redirect: "manual",
75
+ });
76
+
77
+ const respHeaders = new Headers(resp.headers);
78
+ respHeaders.delete("x-frame-options");
79
+ respHeaders.delete("content-security-policy");
80
+
81
+ return new Response(resp.body, {
82
+ status: resp.status,
83
+ statusText: resp.statusText,
84
+ headers: respHeaders,
85
+ });
86
+ } catch {
87
+ return c.text(`Cannot connect to localhost:${port}`, 502);
88
+ }
89
+ });
@@ -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 {
@@ -146,9 +159,7 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
146
159
  });
147
160
  // Store fork source so WS handler knows to use forkSession on first message
148
161
  const provider = providerRegistry.get(providerId);
149
- if (provider && "setForkSource" in provider) {
150
- (provider as any).setForkSource(session.id, sourceId);
151
- }
162
+ provider?.setForkSource?.(session.id, sourceId);
152
163
  return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
153
164
  } catch (e) {
154
165
  return c.json(err((e as Error).message), 500);
@@ -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";
@@ -22,6 +22,7 @@ type ChatWsSocket = {
22
22
  interface SessionEntry {
23
23
  providerId: string;
24
24
  clients: Set<ChatWsSocket>;
25
+ abort?: AbortController;
25
26
  projectPath?: string;
26
27
  projectName?: string;
27
28
  pingIntervals: Map<ChatWsSocket, ReturnType<typeof setInterval>>;
@@ -31,8 +32,6 @@ interface SessionEntry {
31
32
  turnEvents: unknown[];
32
33
  streamPromise?: Promise<void>;
33
34
  permissionMode?: string;
34
- /** Whether the persistent event consumer loop is running */
35
- isStreamingActive: boolean;
36
35
  }
37
36
 
38
37
  /** Tracks active sessions — persists even when FE disconnects */
@@ -126,11 +125,6 @@ function startCleanupTimer(sessionId: string): void {
126
125
  entry.cleanupTimer = setTimeout(() => {
127
126
  console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
128
127
  logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
129
- // Close streaming session in provider
130
- const provider = providerRegistry.get(entry.providerId);
131
- if (provider && "closeStreamingSession" in provider) {
132
- (provider as any).closeStreamingSession(sessionId);
133
- }
134
128
  for (const interval of entry.pingIntervals.values()) clearInterval(interval);
135
129
  entry.pingIntervals.clear();
136
130
  activeSessions.delete(sessionId);
@@ -138,25 +132,28 @@ function startCleanupTimer(sessionId: string): void {
138
132
  }
139
133
 
140
134
  /**
141
- * Persistent event consumerruns for the entire session lifetime.
142
- * First message creates the query; follow-ups push into the provider's
143
- * message channel. Events from ALL turns flow through this single loop.
135
+ * Standalone streaming loopdecoupled from WS message handler.
136
+ * Runs independently so WS close does NOT kill the Claude query.
144
137
  */
145
- async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>): Promise<void> {
138
+ async function runStreamLoop(initialSessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
139
+ let sessionId = initialSessionId;
146
140
  const entry = activeSessions.get(sessionId);
147
141
  if (!entry) {
148
- console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
142
+ console.error(`[chat] session=${sessionId} runStreamLoop: no entry — aborting`);
149
143
  return;
150
144
  }
151
- console.log(`[chat] session=${sessionId} startSessionConsumer started (clients=${entry.clients.size})`);
145
+ const streamStartMs = Date.now();
146
+ console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
152
147
 
153
- entry.isStreamingActive = true;
148
+ const abortController = new AbortController();
149
+ entry.abort = abortController;
154
150
  entry.pendingApprovalEvent = undefined;
155
151
  entry.turnEvents = [];
156
152
  setPhase(sessionId, "connecting");
157
153
 
158
154
  let heartbeat: ReturnType<typeof setInterval> | undefined;
159
155
  let lastContextWindowPct: number | undefined;
156
+ let doneEmitted = false;
160
157
 
161
158
  try {
162
159
  const userPreview = content.slice(0, 200);
@@ -165,12 +162,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
165
162
 
166
163
  let eventCount = 0;
167
164
  let firstEventReceived = false;
168
- let startTime = Date.now();
165
+ const startTime = Date.now();
169
166
 
170
167
  // Heartbeat: while waiting for first response, send elapsed time every 5s
171
168
  const CONNECTION_TIMEOUT_S = 120;
172
169
  heartbeat = setInterval(() => {
173
- if (firstEventReceived) {
170
+ if (firstEventReceived || abortController.signal.aborted) {
174
171
  clearInterval(heartbeat);
175
172
  return;
176
173
  }
@@ -189,23 +186,44 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
189
186
  type: "error",
190
187
  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\``,
191
188
  });
189
+ abortController.abort();
192
190
  return;
193
191
  }
192
+ // Heartbeat uses broadcast() directly — NOT setPhase() (same-phase guard would skip elapsed updates)
194
193
  broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
195
194
  }, 5_000);
196
195
 
197
- for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
196
+ for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
197
+ if (abortController.signal.aborted) break;
198
198
  eventCount++;
199
199
  const ev = event as any;
200
200
  const evType = ev.type ?? "unknown";
201
201
 
202
- // System events transition connecting thinking
202
+ // Session ID migrated: CLI provider assigned a different ID than PPM generated.
203
+ // Migrate activeSessions key so all subsequent events use the real ID.
204
+ if (evType === "session_migrated") {
205
+ const { oldSessionId, newSessionId } = ev;
206
+ const migrated = activeSessions.get(oldSessionId);
207
+ if (migrated) {
208
+ activeSessions.delete(oldSessionId);
209
+ activeSessions.set(newSessionId, migrated);
210
+ sessionId = newSessionId; // update local ref for subsequent setPhase/broadcast calls
211
+ // Notify frontend to update its sessionId state
212
+ broadcast(newSessionId, { type: "session_migrated", oldSessionId, newSessionId });
213
+ console.log(`[chat] session migrated: ${oldSessionId} → ${newSessionId}`);
214
+ logSessionEvent(newSessionId, "INFO", `Session ID migrated from ${oldSessionId}`);
215
+ }
216
+ continue;
217
+ }
218
+
219
+ // System events (hook_started, init, etc.) → transition connecting → thinking
220
+ // These indicate SDK has connected and is processing, but no content yet.
203
221
  if (evType === "system") {
204
222
  if (!firstEventReceived) {
205
223
  if (heartbeat) clearInterval(heartbeat);
206
224
  setPhase(sessionId, "thinking");
207
225
  }
208
- continue;
226
+ continue; // Don't buffer or broadcast system events
209
227
  }
210
228
 
211
229
  // First content event — stop heartbeat, transition phase
@@ -238,11 +256,10 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
238
256
  console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
239
257
  logSessionEvent(sessionId, "ERROR", errorDetail);
240
258
  } else if (evType === "done") {
241
- // Turn complete — transition to idle, clear buffer for next turn
259
+ doneEmitted = true;
242
260
  logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
243
261
  if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
244
-
245
- // Fire-and-forget: title + notification
262
+ // Fire-and-forget: fetch updated session title from SDK summary
246
263
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
247
264
  const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
248
265
  const title = found?.customTitle ?? found?.summary;
@@ -252,6 +269,7 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
252
269
  if (session) session.title = title;
253
270
  }
254
271
  }).catch(() => {});
272
+ // Fire-and-forget notification broadcast (push + telegram)
255
273
  import("../../services/notification.service.ts").then(({ notificationService }) => {
256
274
  const project = entry.projectName || "Project";
257
275
  const session = chatService.getSession(sessionId);
@@ -266,6 +284,7 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
266
284
  }).catch(() => {});
267
285
  } else if (evType === "approval_request") {
268
286
  entry.pendingApprovalEvent = ev;
287
+ // Fire-and-forget notification for approval/question
269
288
  import("../../services/notification.service.ts").then(({ notificationService }) => {
270
289
  const project = entry.projectName || "Project";
271
290
  const session = chatService.getSession(sessionId);
@@ -284,40 +303,32 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
284
303
 
285
304
  // Buffer + broadcast content events
286
305
  bufferAndBroadcast(sessionId, event);
287
-
288
- // After "done", transition to idle + clear turn buffer for next turn
289
- // Consumer loop continues — query waits for next message in generator
290
- if (evType === "done") {
291
- entry.turnEvents = [];
292
- entry.pendingApprovalEvent = undefined;
293
- setPhase(sessionId, "idle");
294
- // Reset heartbeat tracking for next turn
295
- firstEventReceived = false;
296
- startTime = Date.now();
297
- }
298
306
  }
299
307
 
300
- logSessionEvent(sessionId, "INFO", `Session consumer completed (${eventCount} events total)`);
301
- console.log(`[chat] session=${sessionId} session consumer completed (${eventCount} events)`);
308
+ logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
309
+ console.log(`[chat] session=${sessionId} stream completed (${eventCount} events)`);
302
310
  } catch (e) {
303
311
  const errMsg = (e as Error).message;
304
312
  logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
305
- bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
313
+ if (!abortController.signal.aborted) {
314
+ bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
315
+ }
306
316
  } finally {
307
317
  if (heartbeat) clearInterval(heartbeat);
308
- entry.isStreamingActive = false;
318
+ // 1. Buffer and broadcast done event (skip if SDK already yielded one)
319
+ if (!doneEmitted) {
320
+ bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
321
+ }
322
+ // 2. Clear buffer BEFORE setting phase to idle
309
323
  entry.turnEvents = [];
324
+ // 3. Transition to idle
310
325
  setPhase(sessionId, "idle");
326
+ // 4. Cleanup
327
+ entry.abort = undefined;
311
328
  entry.pendingApprovalEvent = undefined;
312
- // Close streaming session in provider
313
- const provider = providerRegistry.get(entry.providerId);
314
- if (provider && "closeStreamingSession" in provider) {
315
- (provider as any).closeStreamingSession(sessionId);
316
- }
317
329
  if (entry.clients.size === 0) {
318
330
  startCleanupTimer(sessionId);
319
331
  }
320
- console.log(`[chat] session=${sessionId} consumer loop ended`);
321
332
  }
322
333
  }
323
334
 
@@ -393,7 +404,6 @@ export const chatWebSocket = {
393
404
  pingIntervals: new Map(),
394
405
  phase: "idle",
395
406
  turnEvents: [],
396
- isStreamingActive: false,
397
407
  };
398
408
  activeSessions.set(sessionId, newEntry);
399
409
  setupClientPing(newEntry, ws);
@@ -443,7 +453,7 @@ export const chatWebSocket = {
443
453
  if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
444
454
  const newEntry: SessionEntry = {
445
455
  providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
446
- pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
456
+ pingIntervals: new Map(), phase: "idle", turnEvents: [],
447
457
  };
448
458
  activeSessions.set(sessionId, newEntry);
449
459
  setupClientPing(newEntry, ws);
@@ -480,78 +490,53 @@ export const chatWebSocket = {
480
490
  ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
481
491
  return;
482
492
  }
483
- // Validate image payload
484
- if (parsed.images?.length) {
485
- if (parsed.images.length > 5) {
486
- ws.send(JSON.stringify({ type: "error", message: "Max 5 images per message" }));
487
- return;
488
- }
489
- const MAX_BASE64_SIZE = 7_000_000; // ~5MB decoded
490
- const SUPPORTED_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
491
- for (const img of parsed.images) {
492
- if (img.data.length > MAX_BASE64_SIZE) {
493
- ws.send(JSON.stringify({ type: "error", message: "Image too large (max 5MB)" }));
494
- return;
495
- }
496
- if (!SUPPORTED_TYPES.has(img.mediaType)) {
497
- ws.send(JSON.stringify({ type: "error", message: `Unsupported image type: ${img.mediaType}` }));
498
- return;
499
- }
500
- }
501
- }
502
493
  // Store permission mode — sticky for this session
503
494
  if (parsed.permissionMode) {
504
495
  entry.permissionMode = parsed.permissionMode;
505
496
  }
506
497
 
498
+ // Resume session in provider (can be slow on first call — sdkListSessions)
507
499
  const provider = providerRegistry.get(providerId);
508
-
509
- if (!entry.isStreamingActive) {
510
- // First message or post-crash recovery: start persistent consumer
511
- // Resume session in provider (can be slow on first call — sdkListSessions)
512
- if (provider && "resumeSession" in provider) {
513
- const t0 = Date.now();
514
- await (provider as any).resumeSession(sessionId);
515
- const elapsed = Date.now() - t0;
516
- if (elapsed > 500) {
517
- console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
518
- logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
519
- }
520
- }
521
- if (entry.projectPath && provider && "ensureProjectPath" in provider) {
522
- (provider as any).ensureProjectPath(sessionId, entry.projectPath);
500
+ if (provider) {
501
+ const t0 = Date.now();
502
+ await provider.resumeSession(sessionId);
503
+ const elapsed = Date.now() - t0;
504
+ if (elapsed > 500) {
505
+ console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
506
+ logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
523
507
  }
508
+ }
509
+ if (entry.projectPath && provider?.ensureProjectPath) {
510
+ provider.ensureProjectPath(sessionId, entry.projectPath);
511
+ }
524
512
 
525
- entry.turnEvents = [];
526
- setPhase(sessionId, "initializing");
527
-
528
- const permMode = entry.permissionMode;
529
- const msgImages = parsed.type === "message" ? parsed.images : undefined;
530
- entry.streamPromise = new Promise<void>((resolve) => {
531
- setTimeout(() => {
532
- startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
533
- }, 0);
534
- });
535
- } else {
536
- // Follow-up: push into existing generator via provider
537
- if (provider && "pushMessage" in provider && parsed.type === "message") {
538
- (provider as any).pushMessage(sessionId, parsed.content, {
539
- priority: parsed.priority ?? 'next',
540
- images: parsed.images,
541
- });
513
+ // Abort-and-replace: if already streaming, abort current query and wait for cleanup
514
+ if (entry.phase !== "idle" && entry.abort) {
515
+ console.log(`[chat] session=${sessionId} aborting current query for new message`);
516
+ entry.abort.abort();
517
+ if (entry.streamPromise) {
518
+ await entry.streamPromise;
542
519
  }
543
- // Clear turn events for new turn display + transition phase
544
- entry.turnEvents = [];
545
- entry.pendingApprovalEvent = undefined;
546
- setPhase(sessionId, "thinking");
547
- console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
520
+ // Re-fetch entry after await may have been mutated during cleanup
521
+ entry = activeSessions.get(sessionId)!;
522
+ if (!entry) return;
548
523
  }
524
+
525
+ // Reset for new query
526
+ entry.turnEvents = [];
527
+ setPhase(sessionId, "initializing");
528
+
529
+ // Store promise reference on entry to prevent GC from collecting the async operation.
530
+ // Use setTimeout(0) to detach from WS handler's async scope.
531
+ const permMode = entry.permissionMode;
532
+ entry.streamPromise = new Promise<void>((resolve) => {
533
+ setTimeout(() => {
534
+ runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
535
+ }, 0);
536
+ });
549
537
  } else if (parsed.type === "cancel") {
550
- // Interrupt current turn — session stays alive for next message
551
538
  const provider = providerRegistry.get(providerId);
552
- if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
553
- (provider as any).abortQuery(sessionId);
554
- }
539
+ provider?.abortQuery?.(sessionId);
555
540
  } else if (parsed.type === "approval_response") {
556
541
  const provider = providerRegistry.get(providerId);
557
542
  if (provider && typeof provider.resolveApproval === "function") {
@@ -574,7 +559,7 @@ export const chatWebSocket = {
574
559
  evictClient(entry, ws);
575
560
  console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
576
561
 
577
- if (entry.clients.size === 0 && !entry.isStreamingActive) {
562
+ if (entry.clients.size === 0 && entry.phase === "idle") {
578
563
  startCleanupTimer(sessionId);
579
564
  }
580
565
  },
@@ -7,7 +7,6 @@ import type {
7
7
  ChatMessage,
8
8
  SendMessageOpts,
9
9
  } from "../providers/provider.interface.ts";
10
- import { MockProvider } from "../providers/mock-provider.ts";
11
10
 
12
11
  class ChatService {
13
12
  async createSession(
@@ -34,19 +33,18 @@ class ChatService {
34
33
  if (providerId) {
35
34
  const provider = providerRegistry.get(providerId);
36
35
  if (!provider) throw new Error(`Provider "${providerId}" not found`);
37
- // Pass dir to providers that support it (SDK provider)
38
- if (dir && "listSessionsByDir" in provider) {
39
- return (provider as any).listSessionsByDir(dir);
36
+ if (dir && provider.listSessionsByDir) {
37
+ return provider.listSessionsByDir(dir);
40
38
  }
41
39
  return provider.listSessions();
42
40
  }
43
41
  // Aggregate from all providers
44
42
  const all: SessionInfo[] = [];
45
- for (const info of providerRegistry.list()) {
43
+ for (const info of providerRegistry.listAll()) {
46
44
  const provider = providerRegistry.get(info.id);
47
45
  if (provider) {
48
- if (dir && "listSessionsByDir" in provider) {
49
- all.push(...await (provider as any).listSessionsByDir(dir));
46
+ if (dir && provider.listSessionsByDir) {
47
+ all.push(...await provider.listSessionsByDir(dir));
50
48
  } else {
51
49
  all.push(...await provider.listSessions());
52
50
  }
@@ -83,13 +81,13 @@ class ChatService {
83
81
 
84
82
  /** Look up a session across all providers (for WS handler) */
85
83
  getSession(sessionId: string): Session | null {
86
- for (const info of providerRegistry.list()) {
84
+ for (const info of providerRegistry.listAll()) {
87
85
  const provider = providerRegistry.get(info.id);
88
86
  if (!provider) continue;
89
- const sessions = (provider as any).sessions as Map<string, unknown> | undefined;
90
- if (sessions?.has(sessionId)) {
87
+ // Use internal sessions Map SDK stores {meta, sdk}, others store Session directly
88
+ const sessions = (provider as any).sessions ?? (provider as any).activeSessions;
89
+ if (sessions instanceof Map && sessions.has(sessionId)) {
91
90
  const entry = sessions.get(sessionId);
92
- // SDK provider stores {meta, sdk}, others store Session directly
93
91
  if (entry && typeof entry === "object" && "meta" in entry) {
94
92
  return (entry as { meta: Session }).meta;
95
93
  }
@@ -102,10 +100,7 @@ class ChatService {
102
100
  async getMessages(providerId: string, sessionId: string): Promise<ChatMessage[]> {
103
101
  const provider = providerRegistry.get(providerId);
104
102
  if (!provider) return [];
105
- if ("getMessages" in provider && typeof (provider as any).getMessages === "function") {
106
- return await (provider as any).getMessages(sessionId);
107
- }
108
- return [];
103
+ return await provider.getMessages?.(sessionId) ?? [];
109
104
  }
110
105
  }
111
106
 
package/src/types/api.ts CHANGED
@@ -23,7 +23,7 @@ export type TerminalWsMessage =
23
23
 
24
24
  /** WebSocket message types (chat) */
25
25
  export type ChatWsClientMessage =
26
- | { type: "message"; content: string; permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }
26
+ | { type: "message"; content: string; permissionMode?: string }
27
27
  | { type: "cancel" }
28
28
  | { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown }
29
29
  | { type: "ready" };