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

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 (156) hide show
  1. package/CHANGELOG.md +27 -8
  2. package/dist/web/assets/{_basePickBy-COwDPZl_.js → _basePickBy-CZovQgWd.js} +1 -1
  3. package/dist/web/assets/{_baseUniq-DCb0mkTp.js → _baseUniq-ClnvscgW.js} +1 -1
  4. package/dist/web/assets/{api-settings-CuUkz5gb.js → api-settings--eVrUeZM.js} +1 -1
  5. package/dist/web/assets/{arc-D0bJaFyD.js → arc-C2Qaz-ch.js} +1 -1
  6. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +1 -0
  7. package/dist/web/assets/{architectureDiagram-2XIMDMQ5-BVEUkQYB.js → architectureDiagram-2XIMDMQ5-Jq91S_rs.js} +1 -1
  8. package/dist/web/assets/{blockDiagram-WCTKOSBZ-CU2t4NHJ.js → blockDiagram-WCTKOSBZ-CKGufRTy.js} +1 -1
  9. package/dist/web/assets/browser-tab-DAvH4mv0.js +1 -0
  10. package/dist/web/assets/{c4Diagram-IC4MRINW-DzjR91sM.js → c4Diagram-IC4MRINW-BNP2L9r_.js} +1 -1
  11. package/dist/web/assets/channel-w7yboq56.js +1 -0
  12. package/dist/web/assets/chat-tab-WEBXxGgN.js +7 -0
  13. package/dist/web/assets/{chunk-4BX2VUAB-0YMkpW2S.js → chunk-4BX2VUAB-BptTlTyl.js} +1 -1
  14. package/dist/web/assets/{chunk-55IACEB6-Dp0pTM5r.js → chunk-55IACEB6-C4mUdyio.js} +1 -1
  15. package/dist/web/assets/{chunk-7E7YKBS2-CuYKSUgJ.js → chunk-7E7YKBS2-6xAQfBwa.js} +1 -1
  16. package/dist/web/assets/{chunk-7R4GIKGN-DvbvLUIN.js → chunk-7R4GIKGN-DXaGAn_K.js} +2 -2
  17. package/dist/web/assets/{chunk-C72U2L5F-CcEW1AMZ.js → chunk-C72U2L5F-DOtEiN5f.js} +1 -1
  18. package/dist/web/assets/{chunk-EGIJ26TM-Cgt-qg75.js → chunk-EGIJ26TM-D0KJTa_T.js} +1 -1
  19. package/dist/web/assets/{chunk-FMBD7UC4-JCLgVcaC.js → chunk-FMBD7UC4-C_1aG0eb.js} +1 -1
  20. package/dist/web/assets/{chunk-GEFDOKGD-B82RP9ow.js → chunk-GEFDOKGD-DwVPiYfW.js} +1 -1
  21. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +2 -0
  22. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +1 -0
  23. package/dist/web/assets/{chunk-JSJVCQXG-Pb-JMOgO.js → chunk-JSJVCQXG-BSrqCL_3.js} +1 -1
  24. package/dist/web/assets/{chunk-KX2RTZJC-BRj-ZEvL.js → chunk-KX2RTZJC-BCxGmbzy.js} +1 -1
  25. package/dist/web/assets/{chunk-KYZI473N-CBRPKraG.js → chunk-KYZI473N-BKO5gMeU.js} +1 -1
  26. package/dist/web/assets/{chunk-L3YUKLVL-DNFj84V6.js → chunk-L3YUKLVL-3wBgkSvL.js} +1 -1
  27. package/dist/web/assets/{chunk-MX3YWQON-BnPzQK-O.js → chunk-MX3YWQON-BgjSEzus.js} +1 -1
  28. package/dist/web/assets/{chunk-NQ4KR5QH-BRj25yO7.js → chunk-NQ4KR5QH-DLrZwBEm.js} +1 -1
  29. package/dist/web/assets/{chunk-O4XLMI2P-BdXwVXjJ.js → chunk-O4XLMI2P-BurQy8tt.js} +1 -1
  30. package/dist/web/assets/{chunk-OZEHJAEY-LfXT4p8B.js → chunk-OZEHJAEY-YTn24bGg.js} +1 -1
  31. package/dist/web/assets/{chunk-PQ6SQG4A-EdgQyTqa.js → chunk-PQ6SQG4A-BxtUGYhW.js} +1 -1
  32. package/dist/web/assets/{chunk-PU5JKC2W-D3thuSok.js → chunk-PU5JKC2W-B66ELkQm.js} +1 -1
  33. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +1 -0
  34. package/dist/web/assets/{chunk-R5LLSJPH-LdG7RqsM.js → chunk-R5LLSJPH-euR2RxLN.js} +1 -1
  35. package/dist/web/assets/{chunk-WL4C6EOR-BHFnnXOt.js → chunk-WL4C6EOR-_2CBOJdI.js} +1 -1
  36. package/dist/web/assets/{chunk-XIRO2GV7-DUmQrLsF.js → chunk-XIRO2GV7-kqQ0g6wW.js} +1 -1
  37. package/dist/web/assets/{chunk-XPW4576I-CsGTseUr.js → chunk-XPW4576I-CtcaMb09.js} +1 -1
  38. package/dist/web/assets/{chunk-XZSTWKYB-5W2emiq4.js → chunk-XZSTWKYB-BYxFzZwS.js} +1 -1
  39. package/dist/web/assets/{chunk-YBOYWFTD-COdZIaX4.js → chunk-YBOYWFTD-Dx_fX35n.js} +1 -1
  40. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +1 -0
  41. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +1 -0
  42. package/dist/web/assets/clone-BSi6cgDh.js +1 -0
  43. package/dist/web/assets/{code-editor-BRMOypkX.js → code-editor-B5sg_uJQ.js} +1 -1
  44. package/dist/web/assets/{cose-bilkent-S5V4N54A-C1QJ6GPW.js → cose-bilkent-S5V4N54A-CHHjH2dV.js} +1 -1
  45. package/dist/web/assets/{dagre-CWo8w9wK.js → dagre-CNtSxiE_.js} +1 -1
  46. package/dist/web/assets/{dagre-KLK3FWXG-Br4t5TRV.js → dagre-KLK3FWXG-ChenfPp1.js} +1 -1
  47. package/dist/web/assets/database-viewer-CwtyWCkE.js +1 -0
  48. package/dist/web/assets/{diagram-E7M64L7V-CkDC2uAj.js → diagram-E7M64L7V-CzKYZM0Y.js} +1 -1
  49. package/dist/web/assets/{diagram-IFDJBPK2-NvhckwcA.js → diagram-IFDJBPK2-ChB_paPo.js} +1 -1
  50. package/dist/web/assets/{diagram-P4PSJMXO--nUaNiyB.js → diagram-P4PSJMXO-D1eW1dkL.js} +1 -1
  51. package/dist/web/assets/{diff-viewer-jDU2bcGj.js → diff-viewer-CzE5M-Wd.js} +1 -1
  52. package/dist/web/assets/{erDiagram-INFDFZHY-DK4QEZYh.js → erDiagram-INFDFZHY-mCvUFSn6.js} +1 -1
  53. package/dist/web/assets/{flowDiagram-PKNHOUZH-B9h_Ba-v.js → flowDiagram-PKNHOUZH-14ohZ1M1.js} +1 -1
  54. package/dist/web/assets/{ganttDiagram-A5KZAMGK-BVlftqyZ.js → ganttDiagram-A5KZAMGK-DIX0pLbk.js} +1 -1
  55. package/dist/web/assets/git-graph-6yxCeeN9.js +1 -0
  56. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +1 -0
  57. package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-L7sj3Bs-.js → gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js} +1 -1
  58. package/dist/web/assets/{graphlib-BbbiUImY.js → graphlib-DhOZxqsh.js} +1 -1
  59. package/dist/web/assets/index-DE8b9u8F.css +2 -0
  60. package/dist/web/assets/index-wuWZBO9y.js +37 -0
  61. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +1 -0
  62. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +2 -0
  63. package/dist/web/assets/input-Brjz2Vv-.js +41 -0
  64. package/dist/web/assets/{isEmpty-DXomfd7J.js → isEmpty-C0YYdhYj.js} +1 -1
  65. package/dist/web/assets/{ishikawaDiagram-PHBUUO56-cW7SMLa_.js → ishikawaDiagram-PHBUUO56-olazD6dZ.js} +1 -1
  66. package/dist/web/assets/{journeyDiagram-4ABVD52K-DFQXUZsc.js → journeyDiagram-4ABVD52K-CttDH9bb.js} +1 -1
  67. package/dist/web/assets/{kanban-definition-K7BYSVSG-BMUhjxqj.js → kanban-definition-K7BYSVSG-BBXbI37U.js} +1 -1
  68. package/dist/web/assets/keybindings-store-mkBHnWN1.js +1 -0
  69. package/dist/web/assets/{line--xyfYP3x.js → line-DBLLF7lH.js} +1 -1
  70. package/dist/web/assets/{linear-BdqW7iQu.js → linear-BLFWatDe.js} +1 -1
  71. package/dist/web/assets/{markdown-renderer-BCjJbGP8.js → markdown-renderer-CxWxvrzT.js} +5 -5
  72. package/dist/web/assets/{mermaid-parser.core-BY8JfkE_.js → mermaid-parser.core-BKiGOTjR.js} +2 -2
  73. package/dist/web/assets/{mindmap-definition-YRQLILUH-DIv-LMXG.js → mindmap-definition-YRQLILUH-DoT7m4Sz.js} +1 -1
  74. package/dist/web/assets/{ordinal-CIoJK3nc.js → ordinal-CCj7PWgZ.js} +1 -1
  75. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +1 -0
  76. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +1 -0
  77. package/dist/web/assets/{pieDiagram-SKSYHLDU-seSK40d1.js → pieDiagram-SKSYHLDU-Bkh2E4zE.js} +1 -1
  78. package/dist/web/assets/postgres-viewer-UP3yv9Yh.js +1 -0
  79. package/dist/web/assets/{quadrantDiagram-337W2JSQ-BaRFqlsA.js → quadrantDiagram-337W2JSQ-B7zgALOL.js} +1 -1
  80. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +1 -0
  81. package/dist/web/assets/{requirementDiagram-Z7DCOOCP-1WWjMQB_.js → requirementDiagram-Z7DCOOCP-D_5GXNRo.js} +1 -1
  82. package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-DEGGYsk7.js → sankeyDiagram-WA2Y5GQK-BA9EFAAe.js} +1 -1
  83. package/dist/web/assets/{sequenceDiagram-2WXFIKYE-BtRvoUTC.js → sequenceDiagram-2WXFIKYE-fyWIrHiG.js} +1 -1
  84. package/dist/web/assets/{settings-store-D3dJqGhB.js → settings-store-Bbhg_ptG.js} +2 -2
  85. package/dist/web/assets/settings-tab-BoBXlVHe.js +1 -0
  86. package/dist/web/assets/sqlite-viewer-lzRVvM5j.js +1 -0
  87. package/dist/web/assets/{stateDiagram-RAJIS63D-C16aO8tn.js → stateDiagram-RAJIS63D-DfRBcaBu.js} +1 -1
  88. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +1 -0
  89. package/dist/web/assets/{tab-store-DSz5PQI0.js → tab-store-DcIBZTD4.js} +1 -1
  90. package/dist/web/assets/{terminal-tab-MRg8y1xF.js → terminal-tab-CAZtLK6i.js} +2 -2
  91. package/dist/web/assets/{timeline-definition-YZTLITO2-DrjxCpEM.js → timeline-definition-YZTLITO2-DYfwJ1jM.js} +1 -1
  92. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +1 -0
  93. package/dist/web/assets/{use-monaco-theme-BQzvItNE.js → use-monaco-theme-vwto-Vlf.js} +1 -1
  94. package/dist/web/assets/{vennDiagram-LZ73GAT5-DfYFnniI.js → vennDiagram-LZ73GAT5-DqbKNRD9.js} +1 -1
  95. package/dist/web/assets/{xychartDiagram-JWTSCODW-BRvXOVlG.js → xychartDiagram-JWTSCODW-DhUL86qT.js} +1 -1
  96. package/dist/web/index.html +10 -12
  97. package/dist/web/sw.js +1 -1
  98. package/package.json +1 -1
  99. package/src/providers/claude-agent-sdk.ts +212 -76
  100. package/src/server/index.ts +4 -1
  101. package/src/server/routes/browser-preview.ts +135 -65
  102. package/src/server/ws/chat.ts +103 -73
  103. package/src/types/api.ts +1 -1
  104. package/src/types/chat.ts +2 -0
  105. package/src/web/components/browser/browser-tab.tsx +105 -224
  106. package/src/web/components/chat/chat-tab.tsx +3 -3
  107. package/src/web/components/chat/message-input.tsx +42 -4
  108. package/src/web/hooks/use-chat.ts +21 -9
  109. package/dist/web/assets/architecture-PBZL5I3N-281eTKQ3.js +0 -1
  110. package/dist/web/assets/arrow-left-C_j9Ki73.js +0 -1
  111. package/dist/web/assets/browser-tab-BhTdeeZd.js +0 -1
  112. package/dist/web/assets/channel-CKNZAqoN.js +0 -1
  113. package/dist/web/assets/chat-tab-ZiiUVOxM.js +0 -7
  114. package/dist/web/assets/chunk-GLR3WWYH-Bx2UL5jF.js +0 -2
  115. package/dist/web/assets/chunk-HHEYEP7N-BnRVfNc5.js +0 -1
  116. package/dist/web/assets/chunk-QZHKN3VN-gaBt0Rbd.js +0 -1
  117. package/dist/web/assets/classDiagram-VBA2DB6C-CqaIqYPn.js +0 -1
  118. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bo5WN2ok.js +0 -1
  119. package/dist/web/assets/clone-DNDy9Sms.js +0 -1
  120. package/dist/web/assets/database-viewer-CEoDpzPz.js +0 -1
  121. package/dist/web/assets/git-graph-DMQzw4Sp.js +0 -1
  122. package/dist/web/assets/gitGraph-HDMCJU4V-D5qEPjgs.js +0 -1
  123. package/dist/web/assets/index-B4Iz1Wbi.css +0 -2
  124. package/dist/web/assets/index-QiSWS6f-.js +0 -37
  125. package/dist/web/assets/info-3K5VOQVL-CbpovIYU.js +0 -1
  126. package/dist/web/assets/infoDiagram-LFFYTUFH-DFh9c-S2.js +0 -2
  127. package/dist/web/assets/input-DGlv6gt_.js +0 -41
  128. package/dist/web/assets/keybindings-store-BplH-yiN.js +0 -1
  129. package/dist/web/assets/packet-RMMSAZCW-BbzPU9BK.js +0 -1
  130. package/dist/web/assets/pie-UPGHQEXC-B0h6hM1j.js +0 -1
  131. package/dist/web/assets/postgres-viewer-s0snZ9CL.js +0 -1
  132. package/dist/web/assets/radar-KQ55EAFF-CHptMqVT.js +0 -1
  133. package/dist/web/assets/settings-tab-2YkgmrY0.js +0 -1
  134. package/dist/web/assets/sqlite-viewer-B5GNwXaG.js +0 -1
  135. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-D7qSAjnK.js +0 -1
  136. package/dist/web/assets/switch-mjGtIVDJ.js +0 -1
  137. package/dist/web/assets/treemap-KZPCXAKY-BL9OJq3X.js +0 -1
  138. /package/dist/web/assets/{api-client-icCZ-07C.js → api-client-DpGMOZNf.js} +0 -0
  139. /package/dist/web/assets/{array-CLwNaqU1.js → array-BGFCBI0e.js} +0 -0
  140. /package/dist/web/assets/{columns-2-Bcg3QJBg.js → columns-2-ChOTgl3e.js} +0 -0
  141. /package/dist/web/assets/{cytoscape.esm-B-QQuWwK.js → cytoscape.esm-Ccan6xou.js} +0 -0
  142. /package/dist/web/assets/{defaultLocale-D_VMtRaY.js → defaultLocale-CRZydyG6.js} +0 -0
  143. /package/dist/web/assets/{dist-Ckxnw5rl.js → dist-Cce3efmT.js} +0 -0
  144. /package/dist/web/assets/{dist-CMmNEgEP.js → dist-T0Vhi0Mh.js} +0 -0
  145. /package/dist/web/assets/{init-vVpfz1D6.js → init-B8gtcn7T.js} +0 -0
  146. /package/dist/web/assets/{isArrayLikeObject-DvHDmeBe.js → isArrayLikeObject-B4pdpV8V.js} +0 -0
  147. /package/dist/web/assets/{katex-C3cZrCvP.js → katex-Bbu770d9.js} +0 -0
  148. /package/dist/web/assets/{math-a44lmFDa.js → math-DwgHI-Cu.js} +0 -0
  149. /package/dist/web/assets/{path-CuyvWNAH.js → path-DZF-JdEe.js} +0 -0
  150. /package/dist/web/assets/{preload-helper-CsoeaaUJ.js → preload-helper-qlgyTAkD.js} +0 -0
  151. /package/dist/web/assets/{react-BPIfZRKM.js → react-BGf7KNLk.js} +0 -0
  152. /package/dist/web/assets/{rough.esm-c4PR5shF.js → rough.esm-VLpapkIG.js} +0 -0
  153. /package/dist/web/assets/{src-CLWraeNW.js → src-BoSBNdA_.js} +0 -0
  154. /package/dist/web/assets/{table-C9jDaRl2.js → table-Yo02WRH-.js} +0 -0
  155. /package/dist/web/assets/{tag-CENGyt_L.js → tag-CaC1ng2E.js} +0 -0
  156. /package/dist/web/assets/{utils-Bslrbb-G.js → utils-btZ8C8-R.js} +0 -0
@@ -25,6 +25,84 @@ function getSdkSessionId(ppmId: string): string {
25
25
  return getSessionMapping(ppmId) ?? ppmId;
26
26
  }
27
27
 
28
+ // ── Streaming Input: message channel for persistent query ──
29
+
30
+ interface MessageController {
31
+ push(msg: any): void;
32
+ done(): void;
33
+ }
34
+
35
+ function createMessageChannel(): {
36
+ generator: AsyncGenerator<any, void, undefined>;
37
+ controller: MessageController;
38
+ } {
39
+ const queue: any[] = [];
40
+ let resolve: ((msg: any) => void) | null = null;
41
+ let isDone = false;
42
+
43
+ async function* gen(): AsyncGenerator<any, void, undefined> {
44
+ while (!isDone) {
45
+ if (queue.length > 0) {
46
+ yield queue.shift()!;
47
+ } else {
48
+ const msg = await new Promise<any>((r) => { resolve = r; });
49
+ if (!isDone) yield msg;
50
+ }
51
+ }
52
+ }
53
+
54
+ return {
55
+ generator: gen(),
56
+ controller: {
57
+ push(msg: any) {
58
+ if (isDone) return;
59
+ if (resolve) {
60
+ const r = resolve;
61
+ resolve = null;
62
+ r(msg);
63
+ } else {
64
+ queue.push(msg);
65
+ }
66
+ },
67
+ done() {
68
+ isDone = true;
69
+ if (resolve) {
70
+ const r = resolve;
71
+ resolve = null;
72
+ r(null); // Unblock pending promise; isDone prevents yield
73
+ }
74
+ },
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Build a MessageParam with optional image content blocks */
80
+ function buildMessageParam(
81
+ text: string,
82
+ images?: Array<{ data: string; mediaType: string }>,
83
+ ): { role: 'user'; content: string | any[] } {
84
+ if (!images || images.length === 0) {
85
+ return { role: 'user' as const, content: text };
86
+ }
87
+ const blocks: any[] = [];
88
+ for (const img of images) {
89
+ blocks.push({
90
+ type: 'image',
91
+ source: { type: 'base64', media_type: img.mediaType, data: img.data },
92
+ });
93
+ }
94
+ if (text.trim()) {
95
+ blocks.push({ type: 'text', text });
96
+ }
97
+ return { role: 'user' as const, content: blocks };
98
+ }
99
+
100
+ interface StreamingSession {
101
+ meta: Session;
102
+ query: any;
103
+ controller: MessageController;
104
+ }
105
+
28
106
  /**
29
107
  * Pending approval: canUseTool callback creates a promise,
30
108
  * yields an approval_request event, then awaits resolution from FE.
@@ -50,6 +128,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
50
128
  private activeQueries = new Map<string, { close: () => void }>();
51
129
  /** Fork source: ppmSessionId → sourceSessionId (used on first message to fork) */
52
130
  private forkSources = new Map<string, string>();
131
+ /** Streaming sessions: persistent query + message channel per session */
132
+ private streamingSessions = new Map<string, StreamingSession>();
53
133
 
54
134
  /** Auth-related env keys for diagnostic logging */
55
135
  private readonly AUTH_ENV_KEYS = ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
@@ -220,6 +300,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
220
300
  }
221
301
 
222
302
  async deleteSession(sessionId: string): Promise<void> {
303
+ this.closeStreamingSession(sessionId);
223
304
  this.activeSessions.delete(sessionId);
224
305
  this.messageCount.delete(sessionId);
225
306
  }
@@ -260,11 +341,63 @@ export class ClaudeAgentSdkProvider implements AIProvider {
260
341
  }
261
342
  }
262
343
 
344
+ /**
345
+ * Push a follow-up message into an existing streaming session's generator.
346
+ * Called by WS handler for follow-up messages (Phase 2).
347
+ */
348
+ pushMessage(sessionId: string, content: string, opts?: { priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }): void {
349
+ const ss = this.streamingSessions.get(sessionId);
350
+ if (!ss) {
351
+ console.warn(`[sdk] pushMessage: no streaming session for ${sessionId}`);
352
+ return;
353
+ }
354
+ const msgContent = buildMessageParam(content, opts?.images);
355
+ ss.controller.push({
356
+ type: 'user',
357
+ message: msgContent,
358
+ parent_tool_use_id: null,
359
+ session_id: sessionId,
360
+ priority: opts?.priority ?? 'next',
361
+ });
362
+ console.log(`[sdk] pushMessage: session=${sessionId} priority=${opts?.priority ?? 'next'}`);
363
+ }
364
+
365
+ /** Close a streaming session — generator + query cleanup */
366
+ closeStreamingSession(sessionId: string): void {
367
+ const ss = this.streamingSessions.get(sessionId);
368
+ if (ss) {
369
+ ss.controller.done();
370
+ ss.query.close();
371
+ this.streamingSessions.delete(sessionId);
372
+ console.log(`[sdk] closeStreamingSession: session=${sessionId}`);
373
+ }
374
+ }
375
+
376
+ /** Check if a streaming session is active for a given session ID */
377
+ hasStreamingSession(sessionId: string): boolean {
378
+ return this.streamingSessions.has(sessionId);
379
+ }
380
+
263
381
  async *sendMessage(
264
382
  sessionId: string,
265
383
  message: string,
266
- opts?: import("./provider.interface.ts").SendMessageOpts & { forkSession?: boolean },
384
+ opts?: import("./provider.interface.ts").SendMessageOpts & { forkSession?: boolean; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> },
267
385
  ): AsyncIterable<ChatEvent> {
386
+ // Follow-up: push into existing streaming session, yield nothing
387
+ const existingStream = this.streamingSessions.get(sessionId);
388
+ if (existingStream) {
389
+ const msgContent = buildMessageParam(message, opts?.images);
390
+ existingStream.controller.push({
391
+ type: 'user',
392
+ message: msgContent,
393
+ parent_tool_use_id: null,
394
+ session_id: sessionId,
395
+ priority: opts?.priority ?? 'next',
396
+ });
397
+ console.log(`[sdk] sendMessage follow-up: session=${sessionId} pushed to generator`);
398
+ return; // Events flow through first-message's consumer loop
399
+ }
400
+
268
401
  if (!this.activeSessions.has(sessionId)) {
269
402
  await this.resumeSession(sessionId);
270
403
  }
@@ -387,6 +520,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
387
520
  let resultSubtype: string | undefined;
388
521
  let resultNumTurns: number | undefined;
389
522
  let resultContextWindowPct: number | undefined;
523
+ let yieldedDone = false;
390
524
  try {
391
525
  // Resolve SDK's actual session ID for resume (may differ from PPM's UUID)
392
526
  // For fork: use the source session's SDK id
@@ -458,14 +592,25 @@ export class ClaudeAgentSdkProvider implements AIProvider {
458
592
  includePartialMessages: true,
459
593
  };
460
594
 
595
+ // Streaming input: create message channel and persistent query
596
+ const { generator: streamGen, controller: streamCtrl } = createMessageChannel();
597
+ const firstMsg = {
598
+ type: 'user' as const,
599
+ message: buildMessageParam(message),
600
+ parent_tool_use_id: null,
601
+ session_id: sessionId,
602
+ };
603
+ streamCtrl.push(firstMsg);
604
+
461
605
  const q = query({
462
- prompt: message,
606
+ prompt: streamGen,
463
607
  options: {
464
608
  ...queryOptions,
465
609
  ...(permissionHooks && { hooks: permissionHooks }),
466
610
  canUseTool,
467
611
  } as any,
468
612
  });
613
+ this.streamingSessions.set(sessionId, { meta, query: q, controller: streamCtrl });
469
614
  this.activeQueries.set(sessionId, q);
470
615
  let eventSource: AsyncIterable<any> = q;
471
616
 
@@ -480,22 +625,29 @@ export class ClaudeAgentSdkProvider implements AIProvider {
480
625
  let retryCount = 0;
481
626
  let authRetried = false;
482
627
 
628
+ let hadAnyEvents = false;
483
629
  retryLoop: while (true) {
484
630
  let sdkEventCount = 0;
485
631
  for await (const msg of eventSource) {
486
632
  sdkEventCount++;
633
+ hadAnyEvents = true;
487
634
  if (sdkEventCount === 1) {
488
635
  console.log(`[sdk] first event received: type=${(msg as any).type} subtype=${(msg as any).subtype ?? "none"}`);
489
636
  // Detect immediate failure: first event is a result with error + 0 turns
490
637
  if ((msg as any).type === "result" && (msg as any).subtype === "error_during_execution" && ((msg as any).num_turns ?? 0) === 0 && retryCount < MAX_RETRIES) {
491
638
  retryCount++;
492
639
  console.warn(`[sdk] transient error on first event — retrying (attempt ${retryCount}/${MAX_RETRIES})`);
493
- // Re-create query for retry don't reuse sessionId in case SDK partially created it
640
+ // Close failed query and old channel, create new channel + query for retry
641
+ streamCtrl.done();
642
+ q.close();
643
+ const { generator: retryGen, controller: retryCtrl } = createMessageChannel();
644
+ retryCtrl.push(firstMsg);
494
645
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined };
495
646
  const rq = query({
496
- prompt: message,
647
+ prompt: retryGen,
497
648
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
498
649
  });
650
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: retryCtrl });
499
651
  this.activeQueries.set(sessionId, rq);
500
652
  eventSource = rq;
501
653
  continue retryLoop;
@@ -641,11 +793,17 @@ export class ClaudeAgentSdkProvider implements AIProvider {
641
793
  const refreshedAccount = accountService.getWithTokens(account.id);
642
794
  if (refreshedAccount) {
643
795
  const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
796
+ // Close failed query and old channel, create new channel + query with refreshed token
797
+ streamCtrl.done();
798
+ q.close();
799
+ const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
800
+ authRetryCtrl.push(firstMsg);
644
801
  const retryOpts = { ...queryOptions, sessionId: undefined, resume: undefined, env: retryEnv };
645
802
  const rq = query({
646
- prompt: message,
803
+ prompt: authRetryGen,
647
804
  options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
648
805
  });
806
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl });
649
807
  this.activeQueries.set(sessionId, rq);
650
808
  eventSource = rq;
651
809
  continue retryLoop;
@@ -717,9 +875,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
717
875
  const errCode = this.detectResultErrorCode(msg);
718
876
  if (errCode === 429) {
719
877
  accountSelector.onRateLimit(account.id);
720
- // Post-stream 429 already has content — surface error to user
878
+ // Post-stream 429 — surface error, continue waiting for next turn
721
879
  yield { type: "error", message: "Rate limited. This account is now on cooldown. Please retry." };
722
- break;
880
+ continue;
723
881
  } else if (errCode === 401) {
724
882
  // Try refresh once
725
883
  try {
@@ -827,7 +985,26 @@ export class ClaudeAgentSdkProvider implements AIProvider {
827
985
  }
828
986
  }
829
987
  }
830
- break;
988
+
989
+ // Streaming input: yield done for this turn, then continue for next turn
990
+ yieldedDone = true;
991
+ yield {
992
+ type: "done",
993
+ sessionId,
994
+ resultSubtype: resultSubtype as any,
995
+ numTurns: resultNumTurns,
996
+ contextWindowPct: resultContextWindowPct,
997
+ };
998
+
999
+ // Reset per-turn state for next turn
1000
+ lastPartialText = "";
1001
+ pendingToolCount = 0;
1002
+ assistantContent = "";
1003
+ resultSubtype = undefined;
1004
+ resultNumTurns = undefined;
1005
+ resultContextWindowPct = undefined;
1006
+ sdkEventCount = 0;
1007
+ continue; // Wait for next turn from generator
831
1008
  }
832
1009
  }
833
1010
 
@@ -836,7 +1013,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
836
1013
  yield approvalEvents.shift()!;
837
1014
  }
838
1015
 
839
- if (sdkEventCount === 0) {
1016
+ if (!hadAnyEvents) {
840
1017
  yield { type: "error", message: "Claude did not respond. Check that 'claude' CLI works in your terminal." };
841
1018
  }
842
1019
  break; // Exit retryLoop — normal completion
@@ -846,88 +1023,47 @@ export class ClaudeAgentSdkProvider implements AIProvider {
846
1023
  console.error(`[sdk] session=${sessionId} cwd=${meta.projectPath} error: ${msg}`);
847
1024
  if (msg.includes("abort") || msg.includes("closed")) {
848
1025
  // User-initiated abort or WS closed — nothing to report
849
- } else if (!isFirstMessage && msg.includes("exited with code")) {
850
- // SDK subprocess crashed during session resume retry as fresh session
851
- console.warn(`[sdk] session resume failed, retrying as fresh session`);
852
- try {
853
- const providerConfig = this.getProviderConfig();
854
- const effectiveCwd = meta.projectPath || homedir();
855
- const retryAccount = accountSelector.isEnabled() ? accountSelector.next() : null;
856
- const queryEnv = this.buildQueryEnv(meta.projectPath, retryAccount);
857
- const retryOptions = {
858
- ...(process.platform === "win32" && { executable: "node" }),
859
- cwd: effectiveCwd,
860
- systemPrompt: systemPromptOpt,
861
- settingSources: ["user", "project"],
862
- env: queryEnv,
863
- settings: { permissions: { allow: [], deny: [] } },
864
- allowedTools,
865
- permissionMode,
866
- allowDangerouslySkipPermissions: isBypass,
867
- ...(providerConfig.model && { model: providerConfig.model }),
868
- maxTurns: providerConfig.max_turns ?? 100,
869
- includePartialMessages: true,
870
- };
871
- const retryQuery = query({
872
- prompt: message,
873
- options: {
874
- ...retryOptions,
875
- ...(permissionHooks && { hooks: permissionHooks }),
876
- canUseTool,
877
- } as any,
878
- });
879
- this.activeQueries.set(sessionId, retryQuery);
880
- for await (const retryMsg of retryQuery) {
881
- if (retryMsg.type === "system") continue;
882
- if (retryMsg.type === "result") {
883
- const r = retryMsg as any;
884
- if (r.subtype && r.subtype !== "success") {
885
- const retryErrors = Array.isArray(r.errors) ? r.errors.join("\n") : "";
886
- yield { type: "error", message: retryErrors || `Agent stopped: ${r.subtype}` };
887
- }
888
- resultSubtype = r.subtype;
889
- resultNumTurns = r.num_turns;
890
- break;
891
- }
892
- if ((retryMsg as any).type === "assistant") {
893
- const content = (retryMsg as any).message?.content;
894
- if (Array.isArray(content)) {
895
- for (const block of content) {
896
- if (block.type === "text" && typeof block.text === "string") {
897
- yield { type: "text", content: block.text };
898
- }
899
- }
900
- }
901
- }
902
- }
903
- } catch (retryErr) {
904
- const retryMsg = (retryErr as Error).message ?? String(retryErr);
905
- console.error(`[sdk] retry also failed: ${retryMsg}`);
906
- yield { type: "error", message: `SDK error: ${msg}` };
907
- }
1026
+ } else if (msg.includes("exited with code")) {
1027
+ // Subprocess crashed session will auto-recover on next message
1028
+ console.warn(`[sdk] session=${sessionId} subprocess crashed: ${msg}`);
1029
+ yield { type: "error", message: `SDK subprocess crashed. Send another message to auto-recover.` };
908
1030
  } else {
909
1031
  yield { type: "error", message: `SDK error: ${msg}` };
910
1032
  }
911
1033
  } finally {
912
1034
  this.activeQueries.delete(sessionId);
1035
+ this.streamingSessions.delete(sessionId);
1036
+ console.log(`[sdk] session=${sessionId} streaming session ended`);
913
1037
  }
914
1038
 
915
- yield {
916
- type: "done",
917
- sessionId,
918
- resultSubtype: resultSubtype as any,
919
- numTurns: resultNumTurns,
920
- contextWindowPct: resultContextWindowPct,
921
- };
1039
+ // Final done event when query ends (crash, close, generator done)
1040
+ // Skip if we already yielded done from the result handler (avoid duplicate)
1041
+ if (!yieldedDone) {
1042
+ yield {
1043
+ type: "done",
1044
+ sessionId,
1045
+ resultSubtype: resultSubtype as any,
1046
+ numTurns: resultNumTurns,
1047
+ contextWindowPct: resultContextWindowPct,
1048
+ };
1049
+ }
922
1050
  }
923
1051
 
924
1052
 
925
- /** Abort an active query for a session */
1053
+ /** Interrupt the current turn — session stays alive for the next message */
926
1054
  abortQuery(sessionId: string): void {
1055
+ const ss = this.streamingSessions.get(sessionId);
1056
+ if (ss && typeof ss.query.interrupt === "function") {
1057
+ ss.query.interrupt().catch(() => {});
1058
+ console.log(`[sdk] abortQuery: interrupted session=${sessionId}`);
1059
+ return;
1060
+ }
1061
+ // Fallback: close query entirely and clean up streaming session
927
1062
  const q = this.activeQueries.get(sessionId);
928
1063
  if (q) {
929
1064
  q.close();
930
1065
  this.activeQueries.delete(sessionId);
1066
+ this.streamingSessions.delete(sessionId);
931
1067
  }
932
1068
  }
933
1069
 
@@ -455,12 +455,15 @@ export async function startServer(options: {
455
455
  }
456
456
  console.log();
457
457
 
458
- // Graceful shutdown — stop server + tunnel + DB on exit
458
+ // Graceful shutdown — stop server + tunnel + preview tunnels + DB on exit
459
459
  const shutdown = () => {
460
460
  try { server.stop(true); } catch {}
461
461
  try {
462
462
  import("../services/tunnel.service.ts").then(({ tunnelService }) => tunnelService.stopTunnel()).catch(() => {});
463
463
  } catch {}
464
+ try {
465
+ import("./routes/browser-preview.ts").then(({ stopAllPreviewTunnels }) => stopAllPreviewTunnels()).catch(() => {});
466
+ } catch {}
464
467
  try {
465
468
  import("../services/db.service.ts").then(({ closeDb }) => closeDb()).catch(() => {});
466
469
  } catch {}
@@ -1,89 +1,159 @@
1
1
  import { Hono } from "hono";
2
+ import { ok, err } from "../../types/api.ts";
3
+ import { ensureCloudflared } from "../../services/cloudflared.service.ts";
2
4
 
3
5
  /**
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).
6
+ * Browser preview APIstarts per-port Cloudflare Quick Tunnels so the
7
+ * frontend can iframe any localhost dev server without CORS/path issues.
8
+ *
9
+ * POST /api/preview/tunnel { port: 3000 } → { url: "https://xxx.trycloudflare.com" }
10
+ * DELETE /api/preview/tunnel/:port → stops tunnel for that port
11
+ * GET /api/preview/tunnels → list active tunnels
7
12
  */
8
13
  export const browserPreviewRoutes = new Hono();
9
14
 
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;
15
+ const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
16
+
17
+ interface ActiveTunnel {
18
+ port: number;
19
+ url: string;
20
+ process: import("bun").Subprocess;
21
+ startedAt: number;
14
22
  }
15
23
 
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);
24
+ /** Active tunnels keyed by port */
25
+ const activeTunnels = new Map<number, ActiveTunnel>();
26
+
27
+ /** Start a tunnel for a localhost port */
28
+ browserPreviewRoutes.post("/tunnel", async (c) => {
29
+ const body = await c.req.json<{ port: number }>().catch(() => null);
30
+ const port = body?.port;
31
+ if (!port || port < 1 || port > 65535) {
32
+ return c.json(err("Invalid port (1-65535)"), 400);
20
33
  }
21
34
 
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}`;
35
+ // Return existing tunnel if already running
36
+ const existing = activeTunnels.get(port);
37
+ if (existing) {
38
+ return c.json(ok({ port, url: existing.url }));
39
+ }
27
40
 
28
41
  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
- });
42
+ const bin = await ensureCloudflared();
43
+ const proc = Bun.spawn(
44
+ [bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
45
+ { stderr: "pipe", stdout: "ignore", stdin: "ignore" },
46
+ );
40
47
 
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");
48
+ // Read stderr to find tunnel URL
49
+ const reader = proc.stderr.getReader();
50
+ const decoder = new TextDecoder();
51
+ const url = await new Promise<string>((resolve, reject) => {
52
+ const timeout = setTimeout(() => {
53
+ try { proc.kill(); } catch {}
54
+ reject(new Error("Tunnel timed out after 30s"));
55
+ }, 30_000);
45
56
 
46
- return new Response(resp.body, {
47
- status: resp.status,
48
- statusText: resp.statusText,
49
- headers: respHeaders,
57
+ let buffer = "";
58
+ let found = false;
59
+ const read = async () => {
60
+ try {
61
+ while (true) {
62
+ const { done, value } = await reader.read();
63
+ if (done) break;
64
+ if (found) continue;
65
+ buffer += decoder.decode(value, { stream: true });
66
+ const match = buffer.match(TUNNEL_URL_REGEX);
67
+ if (match) {
68
+ found = true;
69
+ buffer = "";
70
+ clearTimeout(timeout);
71
+ resolve(match[0]);
72
+ }
73
+ }
74
+ if (!found) {
75
+ clearTimeout(timeout);
76
+ reject(new Error("cloudflared exited without tunnel URL"));
77
+ }
78
+ } catch (e) {
79
+ if (!found) { clearTimeout(timeout); reject(e); }
80
+ }
81
+ };
82
+ read();
50
83
  });
51
- } catch {
52
- return c.text(`Cannot connect to localhost:${port}`, 502);
84
+
85
+ activeTunnels.set(port, { port, url, process: proc, startedAt: Date.now() });
86
+
87
+ // Auto-cleanup when process exits
88
+ proc.exited.then(() => activeTunnels.delete(port)).catch(() => activeTunnels.delete(port));
89
+
90
+ console.log(`[preview] tunnel started for port ${port} → ${url}`);
91
+ return c.json(ok({ port, url }));
92
+ } catch (e: any) {
93
+ return c.json(err(e.message || "Failed to start tunnel"), 500);
53
94
  }
54
95
  });
55
96
 
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);
97
+ /** Stop a tunnel */
98
+ browserPreviewRoutes.delete("/tunnel/:port{[0-9]+}", (c) => {
99
+ const port = parseInt(c.req.param("port"), 10);
100
+ const tunnel = activeTunnels.get(port);
101
+ if (!tunnel) {
102
+ return c.json(err("No tunnel running for this port"), 404);
61
103
  }
62
104
 
63
- const url = new URL(c.req.url);
64
- const targetUrl = `http://localhost:${port}/${url.search}`;
105
+ try { tunnel.process.kill(); } catch {}
106
+ activeTunnels.delete(port);
107
+ console.log(`[preview] tunnel stopped for port ${port}`);
108
+ return c.json(ok({ port }));
109
+ });
65
110
 
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
- });
111
+ /** List active tunnels */
112
+ browserPreviewRoutes.get("/tunnels", (c) => {
113
+ const list = Array.from(activeTunnels.values()).map((t) => ({
114
+ port: t.port,
115
+ url: t.url,
116
+ startedAt: t.startedAt,
117
+ }));
118
+ return c.json(ok(list));
119
+ });
76
120
 
77
- const respHeaders = new Headers(resp.headers);
78
- respHeaders.delete("x-frame-options");
79
- respHeaders.delete("content-security-policy");
121
+ /** Check if a cloudflared process is still alive */
122
+ function isProcessAlive(proc: import("bun").Subprocess): boolean {
123
+ try { process.kill(proc.pid, 0); return true; } catch { return false; }
124
+ }
80
125
 
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);
126
+ /** Remove ghost tunnels (process died or target port no longer listening) */
127
+ async function cleanupGhostTunnels() {
128
+ for (const [port, tunnel] of activeTunnels) {
129
+ // Check if cloudflared process is still running
130
+ if (!isProcessAlive(tunnel.process)) {
131
+ console.log(`[preview] ghost cleanup: tunnel for port ${port} process dead`);
132
+ activeTunnels.delete(port);
133
+ continue;
134
+ }
135
+ // Check if target port is still listening
136
+ try {
137
+ const conn = await Bun.connect({ hostname: "127.0.0.1", port, socket: {
138
+ data() {}, open(s) { s.end(); }, error() {}, close() {},
139
+ }});
140
+ conn.end();
141
+ } catch {
142
+ // Port not listening — kill tunnel
143
+ console.log(`[preview] ghost cleanup: tunnel for port ${port} — port not listening`);
144
+ try { tunnel.process.kill(); } catch {}
145
+ activeTunnels.delete(port);
146
+ }
88
147
  }
89
- });
148
+ }
149
+
150
+ // Run ghost cleanup every 30s
151
+ setInterval(cleanupGhostTunnels, 30_000);
152
+
153
+ /** Cleanup all tunnels on server shutdown */
154
+ export function stopAllPreviewTunnels() {
155
+ for (const [port, tunnel] of activeTunnels) {
156
+ try { tunnel.process.kill(); } catch {}
157
+ activeTunnels.delete(port);
158
+ }
159
+ }