@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
@@ -22,7 +22,6 @@ type ChatWsSocket = {
22
22
  interface SessionEntry {
23
23
  providerId: string;
24
24
  clients: Set<ChatWsSocket>;
25
- abort?: AbortController;
26
25
  projectPath?: string;
27
26
  projectName?: string;
28
27
  pingIntervals: Map<ChatWsSocket, ReturnType<typeof setInterval>>;
@@ -32,6 +31,8 @@ interface SessionEntry {
32
31
  turnEvents: unknown[];
33
32
  streamPromise?: Promise<void>;
34
33
  permissionMode?: string;
34
+ /** Whether the persistent event consumer loop is running */
35
+ isStreamingActive: boolean;
35
36
  }
36
37
 
37
38
  /** Tracks active sessions — persists even when FE disconnects */
@@ -125,6 +126,11 @@ function startCleanupTimer(sessionId: string): void {
125
126
  entry.cleanupTimer = setTimeout(() => {
126
127
  console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
127
128
  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
+ }
128
134
  for (const interval of entry.pingIntervals.values()) clearInterval(interval);
129
135
  entry.pingIntervals.clear();
130
136
  activeSessions.delete(sessionId);
@@ -132,28 +138,25 @@ function startCleanupTimer(sessionId: string): void {
132
138
  }
133
139
 
134
140
  /**
135
- * Standalone streaming loopdecoupled from WS message handler.
136
- * Runs independently so WS close does NOT kill the Claude query.
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.
137
144
  */
138
- async function runStreamLoop(initialSessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
139
- let sessionId = initialSessionId;
145
+ async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>): Promise<void> {
140
146
  const entry = activeSessions.get(sessionId);
141
147
  if (!entry) {
142
- console.error(`[chat] session=${sessionId} runStreamLoop: no entry — aborting`);
148
+ console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
143
149
  return;
144
150
  }
145
- const streamStartMs = Date.now();
146
- console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
151
+ console.log(`[chat] session=${sessionId} startSessionConsumer started (clients=${entry.clients.size})`);
147
152
 
148
- const abortController = new AbortController();
149
- entry.abort = abortController;
153
+ entry.isStreamingActive = true;
150
154
  entry.pendingApprovalEvent = undefined;
151
155
  entry.turnEvents = [];
152
156
  setPhase(sessionId, "connecting");
153
157
 
154
158
  let heartbeat: ReturnType<typeof setInterval> | undefined;
155
159
  let lastContextWindowPct: number | undefined;
156
- let doneEmitted = false;
157
160
 
158
161
  try {
159
162
  const userPreview = content.slice(0, 200);
@@ -162,12 +165,12 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
162
165
 
163
166
  let eventCount = 0;
164
167
  let firstEventReceived = false;
165
- const startTime = Date.now();
168
+ let startTime = Date.now();
166
169
 
167
170
  // Heartbeat: while waiting for first response, send elapsed time every 5s
168
171
  const CONNECTION_TIMEOUT_S = 120;
169
172
  heartbeat = setInterval(() => {
170
- if (firstEventReceived || abortController.signal.aborted) {
173
+ if (firstEventReceived) {
171
174
  clearInterval(heartbeat);
172
175
  return;
173
176
  }
@@ -186,15 +189,12 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
186
189
  type: "error",
187
190
  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
191
  });
189
- abortController.abort();
190
192
  return;
191
193
  }
192
- // Heartbeat uses broadcast() directly — NOT setPhase() (same-phase guard would skip elapsed updates)
193
194
  broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
194
195
  }, 5_000);
195
196
 
196
- for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
197
- if (abortController.signal.aborted) break;
197
+ for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
198
198
  eventCount++;
199
199
  const ev = event as any;
200
200
  const evType = ev.type ?? "unknown";
@@ -216,14 +216,13 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
216
216
  continue;
217
217
  }
218
218
 
219
- // System events (hook_started, init, etc.) → transition connecting → thinking
220
- // These indicate SDK has connected and is processing, but no content yet.
219
+ // System events → transition connecting → thinking
221
220
  if (evType === "system") {
222
221
  if (!firstEventReceived) {
223
222
  if (heartbeat) clearInterval(heartbeat);
224
223
  setPhase(sessionId, "thinking");
225
224
  }
226
- continue; // Don't buffer or broadcast system events
225
+ continue;
227
226
  }
228
227
 
229
228
  // First content event — stop heartbeat, transition phase
@@ -256,10 +255,11 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
256
255
  console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
257
256
  logSessionEvent(sessionId, "ERROR", errorDetail);
258
257
  } else if (evType === "done") {
259
- doneEmitted = true;
258
+ // Turn complete — transition to idle, clear buffer for next turn
260
259
  logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
261
260
  if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
262
- // Fire-and-forget: fetch updated session title from SDK summary
261
+
262
+ // Fire-and-forget: title + notification
263
263
  sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
264
264
  const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
265
265
  const title = found?.customTitle ?? found?.summary;
@@ -269,7 +269,6 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
269
269
  if (session) session.title = title;
270
270
  }
271
271
  }).catch(() => {});
272
- // Fire-and-forget notification broadcast (push + telegram)
273
272
  import("../../services/notification.service.ts").then(({ notificationService }) => {
274
273
  const project = entry.projectName || "Project";
275
274
  const session = chatService.getSession(sessionId);
@@ -284,7 +283,6 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
284
283
  }).catch(() => {});
285
284
  } else if (evType === "approval_request") {
286
285
  entry.pendingApprovalEvent = ev;
287
- // Fire-and-forget notification for approval/question
288
286
  import("../../services/notification.service.ts").then(({ notificationService }) => {
289
287
  const project = entry.projectName || "Project";
290
288
  const session = chatService.getSession(sessionId);
@@ -303,32 +301,40 @@ async function runStreamLoop(initialSessionId: string, providerId: string, conte
303
301
 
304
302
  // Buffer + broadcast content events
305
303
  bufferAndBroadcast(sessionId, event);
304
+
305
+ // After "done", transition to idle + clear turn buffer for next turn
306
+ // Consumer loop continues — query waits for next message in generator
307
+ if (evType === "done") {
308
+ entry.turnEvents = [];
309
+ entry.pendingApprovalEvent = undefined;
310
+ setPhase(sessionId, "idle");
311
+ // Reset heartbeat tracking for next turn
312
+ firstEventReceived = false;
313
+ startTime = Date.now();
314
+ }
306
315
  }
307
316
 
308
- logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
309
- console.log(`[chat] session=${sessionId} stream completed (${eventCount} events)`);
317
+ logSessionEvent(sessionId, "INFO", `Session consumer completed (${eventCount} events total)`);
318
+ console.log(`[chat] session=${sessionId} session consumer completed (${eventCount} events)`);
310
319
  } catch (e) {
311
320
  const errMsg = (e as Error).message;
312
321
  logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
313
- if (!abortController.signal.aborted) {
314
- bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
315
- }
322
+ bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
316
323
  } finally {
317
324
  if (heartbeat) clearInterval(heartbeat);
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
325
+ entry.isStreamingActive = false;
323
326
  entry.turnEvents = [];
324
- // 3. Transition to idle
325
327
  setPhase(sessionId, "idle");
326
- // 4. Cleanup
327
- entry.abort = undefined;
328
328
  entry.pendingApprovalEvent = undefined;
329
+ // Close streaming session in provider
330
+ const provider = providerRegistry.get(entry.providerId);
331
+ if (provider && "closeStreamingSession" in provider) {
332
+ (provider as any).closeStreamingSession(sessionId);
333
+ }
329
334
  if (entry.clients.size === 0) {
330
335
  startCleanupTimer(sessionId);
331
336
  }
337
+ console.log(`[chat] session=${sessionId} consumer loop ended`);
332
338
  }
333
339
  }
334
340
 
@@ -404,6 +410,7 @@ export const chatWebSocket = {
404
410
  pingIntervals: new Map(),
405
411
  phase: "idle",
406
412
  turnEvents: [],
413
+ isStreamingActive: false,
407
414
  };
408
415
  activeSessions.set(sessionId, newEntry);
409
416
  setupClientPing(newEntry, ws);
@@ -453,7 +460,7 @@ export const chatWebSocket = {
453
460
  if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
454
461
  const newEntry: SessionEntry = {
455
462
  providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
456
- pingIntervals: new Map(), phase: "idle", turnEvents: [],
463
+ pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
457
464
  };
458
465
  activeSessions.set(sessionId, newEntry);
459
466
  setupClientPing(newEntry, ws);
@@ -490,51 +497,74 @@ export const chatWebSocket = {
490
497
  ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
491
498
  return;
492
499
  }
500
+ // Validate image payload
501
+ if (parsed.images?.length) {
502
+ if (parsed.images.length > 5) {
503
+ ws.send(JSON.stringify({ type: "error", message: "Max 5 images per message" }));
504
+ return;
505
+ }
506
+ const MAX_BASE64_SIZE = 7_000_000; // ~5MB decoded
507
+ const SUPPORTED_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
508
+ for (const img of parsed.images) {
509
+ if (img.data.length > MAX_BASE64_SIZE) {
510
+ ws.send(JSON.stringify({ type: "error", message: "Image too large (max 5MB)" }));
511
+ return;
512
+ }
513
+ if (!SUPPORTED_TYPES.has(img.mediaType)) {
514
+ ws.send(JSON.stringify({ type: "error", message: `Unsupported image type: ${img.mediaType}` }));
515
+ return;
516
+ }
517
+ }
518
+ }
493
519
  // Store permission mode — sticky for this session
494
520
  if (parsed.permissionMode) {
495
521
  entry.permissionMode = parsed.permissionMode;
496
522
  }
497
523
 
498
- // Resume session in provider (can be slow on first call — sdkListSessions)
499
524
  const provider = providerRegistry.get(providerId);
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`);
525
+
526
+ if (!entry.isStreamingActive) {
527
+ // First message or post-crash recovery: start persistent consumer
528
+ // Resume session in provider (can be slow on first call — sdkListSessions)
529
+ if (provider && "resumeSession" in provider) {
530
+ const t0 = Date.now();
531
+ await (provider as any).resumeSession(sessionId);
532
+ const elapsed = Date.now() - t0;
533
+ if (elapsed > 500) {
534
+ console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
535
+ logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
536
+ }
537
+ }
538
+ if (entry.projectPath && provider && "ensureProjectPath" in provider) {
539
+ (provider as any).ensureProjectPath(sessionId, entry.projectPath);
507
540
  }
508
- }
509
- if (entry.projectPath && provider?.ensureProjectPath) {
510
- provider.ensureProjectPath(sessionId, entry.projectPath);
511
- }
512
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
+ entry.turnEvents = [];
543
+ setPhase(sessionId, "initializing");
544
+
545
+ const permMode = entry.permissionMode;
546
+ const msgImages = parsed.type === "message" ? parsed.images : undefined;
547
+ entry.streamPromise = new Promise<void>((resolve) => {
548
+ setTimeout(() => {
549
+ startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
550
+ }, 0);
551
+ });
552
+ } else {
553
+ // Follow-up: push into existing generator via provider
554
+ if (provider && "pushMessage" in provider && parsed.type === "message") {
555
+ (provider as any).pushMessage(sessionId, parsed.content, {
556
+ priority: parsed.priority ?? 'next',
557
+ images: parsed.images,
558
+ });
519
559
  }
520
- // Re-fetch entry after await may have been mutated during cleanup
521
- entry = activeSessions.get(sessionId)!;
522
- if (!entry) return;
560
+ // Clear turn events for new turn display + transition phase
561
+ entry.turnEvents = [];
562
+ entry.pendingApprovalEvent = undefined;
563
+ setPhase(sessionId, "thinking");
564
+ console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
523
565
  }
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
- });
537
566
  } else if (parsed.type === "cancel") {
567
+ // Interrupt current turn — session stays alive for next message
538
568
  const provider = providerRegistry.get(providerId);
539
569
  provider?.abortQuery?.(sessionId);
540
570
  } else if (parsed.type === "approval_response") {
@@ -559,7 +589,7 @@ export const chatWebSocket = {
559
589
  evictClient(entry, ws);
560
590
  console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
561
591
 
562
- if (entry.clients.size === 0 && entry.phase === "idle") {
592
+ if (entry.clients.size === 0 && !entry.isStreamingActive) {
563
593
  startCleanupTimer(sessionId);
564
594
  }
565
595
  },
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 }
26
+ | { type: "message"; content: string; permissionMode?: string; priority?: 'now' | 'next' | 'later'; images?: Array<{ data: string; mediaType: string }> }
27
27
  | { type: "cancel" }
28
28
  | { type: "approval_response"; requestId: string; approved: boolean; reason?: string; data?: unknown }
29
29
  | { type: "ready" };
package/src/types/chat.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export interface SendMessageOpts {
2
2
  permissionMode?: import("./config").PermissionMode | string;
3
+ priority?: 'now' | 'next' | 'later';
4
+ images?: Array<{ data: string; mediaType: string }>;
3
5
  }
4
6
 
5
7
  export interface AIProvider {