@hienlh/ppm 0.9.0-beta.2 → 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 (187) hide show
  1. package/CHANGELOG.md +10 -26
  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-CQ7gq0Vj.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-BjtTemkK.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-BtPXdzTv.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--Gw14HP3.js → terminal-tab-MRg8y1xF.js} +2 -2
  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 +9 -0
  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 +24 -8
  118. package/src/services/chat.service.ts +10 -15
  119. package/src/types/chat.ts +21 -2
  120. package/src/types/config.ts +33 -11
  121. package/src/utils/ndjson-line-parser.ts +36 -0
  122. package/src/web/components/browser/browser-tab.tsx +269 -0
  123. package/src/web/components/chat/chat-history-bar.tsx +49 -29
  124. package/src/web/components/chat/chat-tab.tsx +14 -2
  125. package/src/web/components/chat/message-input.tsx +91 -3
  126. package/src/web/components/chat/provider-selector.tsx +150 -0
  127. package/src/web/components/chat/session-picker.tsx +3 -1
  128. package/src/web/components/layout/command-palette.tsx +4 -0
  129. package/src/web/components/layout/editor-panel.tsx +1 -0
  130. package/src/web/components/layout/mobile-nav.tsx +2 -2
  131. package/src/web/components/layout/panel-layout.tsx +17 -1
  132. package/src/web/components/layout/tab-bar.tsx +2 -0
  133. package/src/web/components/layout/tab-content.tsx +5 -0
  134. package/src/web/components/settings/ai-settings-section.tsx +196 -137
  135. package/src/web/hooks/use-chat.ts +11 -0
  136. package/src/web/hooks/use-global-keybindings.ts +7 -0
  137. package/src/web/hooks/use-voice-input.ts +111 -0
  138. package/src/web/stores/keybindings-store.ts +1 -0
  139. package/src/web/stores/panel-store.ts +10 -10
  140. package/src/web/stores/tab-store.ts +2 -1
  141. package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
  142. package/dist/web/assets/channel-w7yboq56.js +0 -1
  143. package/dist/web/assets/chat-tab-DmF14O6G.js +0 -7
  144. package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
  145. package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
  146. package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
  147. package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
  148. package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
  149. package/dist/web/assets/clone-BSi6cgDh.js +0 -1
  150. package/dist/web/assets/database-viewer-B27aRtdQ.js +0 -1
  151. package/dist/web/assets/git-graph-BGXo0o-J.js +0 -1
  152. package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
  153. package/dist/web/assets/index-BAioKo_2.css +0 -2
  154. package/dist/web/assets/index-CfClIVo2.js +0 -37
  155. package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
  156. package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
  157. package/dist/web/assets/input-Brjz2Vv-.js +0 -41
  158. package/dist/web/assets/keybindings-store-nDbczFnq.js +0 -1
  159. package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
  160. package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
  161. package/dist/web/assets/postgres-viewer-BMg-qFcO.js +0 -1
  162. package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
  163. package/dist/web/assets/settings-tab-NPuwQHzs.js +0 -1
  164. package/dist/web/assets/sqlite-viewer-CAsUczio.js +0 -1
  165. package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
  166. package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
  167. package/snapshot-state.md +0 -1526
  168. package/test-tokens.mjs +0 -212
  169. /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-icCZ-07C.js} +0 -0
  170. /package/dist/web/assets/{array-BGFCBI0e.js → array-CLwNaqU1.js} +0 -0
  171. /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-Bcg3QJBg.js} +0 -0
  172. /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-B-QQuWwK.js} +0 -0
  173. /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-D_VMtRaY.js} +0 -0
  174. /package/dist/web/assets/{dist-T0Vhi0Mh.js → dist-CMmNEgEP.js} +0 -0
  175. /package/dist/web/assets/{dist-Cce3efmT.js → dist-Ckxnw5rl.js} +0 -0
  176. /package/dist/web/assets/{init-B8gtcn7T.js → init-vVpfz1D6.js} +0 -0
  177. /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-DvHDmeBe.js} +0 -0
  178. /package/dist/web/assets/{katex-Bbu770d9.js → katex-C3cZrCvP.js} +0 -0
  179. /package/dist/web/assets/{math-DwgHI-Cu.js → math-a44lmFDa.js} +0 -0
  180. /package/dist/web/assets/{path-DZF-JdEe.js → path-CuyvWNAH.js} +0 -0
  181. /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-CsoeaaUJ.js} +0 -0
  182. /package/dist/web/assets/{react-BGf7KNLk.js → react-BPIfZRKM.js} +0 -0
  183. /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-c4PR5shF.js} +0 -0
  184. /package/dist/web/assets/{src-BoSBNdA_.js → src-CLWraeNW.js} +0 -0
  185. /package/dist/web/assets/{table-Yo02WRH-.js → table-C9jDaRl2.js} +0 -0
  186. /package/dist/web/assets/{tag-CaC1ng2E.js → tag-CENGyt_L.js} +0 -0
  187. /package/dist/web/assets/{utils-btZ8C8-R.js → utils-Bslrbb-G.js} +0 -0
@@ -0,0 +1,207 @@
1
+ import { createHash } from "node:crypto";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import type { ChatMessage, SessionInfo } from "../provider.interface.ts";
6
+
7
+ const DEFAULT_CHATS_DIR = join(homedir(), ".cursor", "chats");
8
+
9
+ /**
10
+ * List all Cursor sessions found in ~/.cursor/chats/.
11
+ * Scans directory structure: {cwdHash}/{sessionId}/store.db
12
+ * Reads meta table for session name and createdAt.
13
+ * @param chatsDir — override for testing (defaults to ~/.cursor/chats)
14
+ */
15
+ export async function listCursorSessions(providerId: string, chatsDir?: string): Promise<SessionInfo[]> {
16
+ const dir = chatsDir ?? DEFAULT_CHATS_DIR;
17
+ if (!existsSync(dir)) return [];
18
+ const { Database } = await import("bun:sqlite");
19
+ const sessions: SessionInfo[] = [];
20
+
21
+ try {
22
+ for (const cwdHash of readdirSync(dir)) {
23
+ const cwdDir = join(dir, cwdHash);
24
+ try {
25
+ for (const sessionId of readdirSync(cwdDir)) {
26
+ const dbPath = join(cwdDir, sessionId, "store.db");
27
+ if (!existsSync(dbPath)) continue;
28
+
29
+ let title = `Cursor ${sessionId.slice(0, 8)}`;
30
+ let createdAt = new Date().toISOString();
31
+
32
+ // Read meta table for name + createdAt (value is hex-encoded JSON)
33
+ try {
34
+ const db = new Database(dbPath, { readonly: true });
35
+ const row = db.query("SELECT value FROM meta LIMIT 1").get() as { value: string | Buffer } | null;
36
+ db.close();
37
+ if (row?.value) {
38
+ const hex = typeof row.value === "string" ? row.value : Buffer.from(row.value).toString("utf-8");
39
+ const json = Buffer.from(hex, "hex").toString("utf-8");
40
+ const meta = JSON.parse(json);
41
+ if (meta.name) title = meta.name.split("\n")[0].slice(0, 80);
42
+ if (meta.createdAt) createdAt = new Date(meta.createdAt).toISOString();
43
+ }
44
+ } catch { /* use defaults */ }
45
+
46
+ sessions.push({ id: sessionId, providerId, title, createdAt });
47
+ }
48
+ } catch { /* skip unreadable dir */ }
49
+ }
50
+ } catch { /* skip if chats dir unreadable */ }
51
+
52
+ return sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
53
+ }
54
+
55
+ /**
56
+ * Load chat history from Cursor's SQLite DAG storage.
57
+ * Path: ~/.cursor/chats/{MD5(cwd)}/{sessionId}/store.db
58
+ * Falls back to scanning all cwdHash dirs if projectPath doesn't match.
59
+ */
60
+ export async function loadCursorHistory(
61
+ sessionId: string,
62
+ projectPath?: string,
63
+ chatsDir?: string,
64
+ ): Promise<ChatMessage[]> {
65
+ const baseDir = chatsDir ?? DEFAULT_CHATS_DIR;
66
+ let dbPath: string | null = null;
67
+
68
+ // Try direct path first (fast path when projectPath is known)
69
+ if (projectPath) {
70
+ const cwdHash = createHash("md5").update(projectPath).digest("hex");
71
+ const candidate = join(baseDir, cwdHash, sessionId, "store.db");
72
+ if (existsSync(candidate)) dbPath = candidate;
73
+ }
74
+
75
+ // Fallback: scan all cwdHash dirs for this sessionId
76
+ if (!dbPath && existsSync(baseDir)) {
77
+ try {
78
+ for (const cwdHash of readdirSync(baseDir)) {
79
+ const candidate = join(baseDir, cwdHash, sessionId, "store.db");
80
+ if (existsSync(candidate)) { dbPath = candidate; break; }
81
+ }
82
+ } catch { /* skip */ }
83
+ }
84
+
85
+ if (!dbPath) return [];
86
+
87
+ try {
88
+ // Use Bun's native SQLite
89
+ const { Database } = await import("bun:sqlite");
90
+ const db = new Database(dbPath, { readonly: true });
91
+
92
+ const blobs = db
93
+ .query("SELECT rowid, id, data FROM blobs ORDER BY rowid")
94
+ .all() as Array<{ rowid: number; id: string; data: Buffer }>;
95
+ db.close();
96
+
97
+ return parseDagBlobs(blobs, sessionId);
98
+ } catch (err) {
99
+ console.warn(`[cursor-history] Failed to load session ${sessionId}:`, err);
100
+ return [];
101
+ }
102
+ }
103
+
104
+ /** Parse DAG blobs into ordered ChatMessages */
105
+ function parseDagBlobs(
106
+ blobs: Array<{ rowid: number; id: string; data: Buffer }>,
107
+ _sessionId: string,
108
+ ): ChatMessage[] {
109
+ const messages: ChatMessage[] = [];
110
+
111
+ for (const blob of blobs) {
112
+ try {
113
+ const text = extractTextContent(blob.data);
114
+ if (!text) continue;
115
+
116
+ // Try to parse as JSON
117
+ try {
118
+ const parsed = JSON.parse(text);
119
+
120
+ // Format: { role: "...", content: "..." | [...] } — structured message
121
+ // Skip system prompts — they're huge and not useful for history
122
+ if (parsed.role === "system") continue;
123
+ if (parsed.role && parsed.content) {
124
+ let content: string;
125
+ if (typeof parsed.content === "string") {
126
+ content = parsed.content;
127
+ } else if (Array.isArray(parsed.content)) {
128
+ // Content parts: [{ type: "text", text: "..." }, ...]
129
+ content = parsed.content
130
+ .filter((p: any) => p.type === "text" && p.text)
131
+ .map((p: any) => p.text)
132
+ .join("\n") || JSON.stringify(parsed.content);
133
+ } else {
134
+ content = JSON.stringify(parsed.content);
135
+ }
136
+ // Filter out Cursor's injected <user_info> system context messages
137
+ if (parsed.role === "user" && /^<user_info>\s/i.test(content.trimStart())) continue;
138
+ // Strip Cursor's <user_query> wrapper from user messages
139
+ if (parsed.role === "user") {
140
+ const match = content.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
141
+ if (match?.[1]) content = match[1];
142
+ }
143
+ messages.push({
144
+ id: blob.id,
145
+ role: parsed.role,
146
+ content,
147
+ timestamp: new Date().toISOString(),
148
+ });
149
+ continue;
150
+ }
151
+
152
+ // Format: [{ type: "text", text: "..." }] — content parts array
153
+ if (Array.isArray(parsed)) {
154
+ const textParts = parsed
155
+ .filter((p: any) => p.type === "text" && p.text)
156
+ .map((p: any) => p.text)
157
+ .join("\n");
158
+ if (textParts) {
159
+ messages.push({
160
+ id: blob.id,
161
+ role: messages.length % 2 === 0 ? "user" : "assistant",
162
+ content: textParts,
163
+ timestamp: new Date().toISOString(),
164
+ });
165
+ continue;
166
+ }
167
+ }
168
+ } catch { /* not JSON */ }
169
+ } catch { /* skip corrupt blob */ }
170
+ }
171
+
172
+ return messages;
173
+ }
174
+
175
+ /**
176
+ * Extract readable text from a DAG blob.
177
+ * Handles 2 known formats:
178
+ * 1. UTF-8 JSON string starting with { or [ (role/content messages)
179
+ * 2. JSON array (content parts like [{type:"text",text:"..."}])
180
+ * Skips binary DAG metadata blobs (parent refs, headers).
181
+ */
182
+ function extractTextContent(data: Buffer | Uint8Array): string | null {
183
+ if (!data || data.length === 0) return null;
184
+
185
+ const buf = Buffer.from(data);
186
+
187
+ // Quick binary check: if first byte is not printable ASCII, it's a DAG metadata blob
188
+ const firstByte = buf[0];
189
+ if (firstByte !== undefined && firstByte < 0x20 && firstByte !== 0x0a && firstByte !== 0x0d && firstByte !== 0x09) {
190
+ return null;
191
+ }
192
+
193
+ const text = buf.toString("utf-8");
194
+
195
+ // Only accept clean JSON starting with { or [
196
+ if (text.startsWith("{") || text.startsWith("[")) {
197
+ // Validate it's actually parseable JSON
198
+ try {
199
+ JSON.parse(text);
200
+ return text;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ return null;
207
+ }
@@ -0,0 +1,146 @@
1
+ import { CliProvider } from "../cli-provider-base.ts";
2
+ import { mapCursorEvent } from "./cursor-event-mapper.ts";
3
+ import { listCursorSessions, loadCursorHistory } from "./cursor-history.ts";
4
+ import type { ChatEvent, ChatMessage, SessionInfo, ModelOption } from "../provider.interface.ts";
5
+ import type { ChildProcess } from "node:child_process";
6
+
7
+ const TRUST_PATTERNS = [
8
+ /workspace trust required/i,
9
+ /do you trust the contents/i,
10
+ /pass --trust/i,
11
+ ];
12
+
13
+ /**
14
+ * Cursor CLI provider — spawns `cursor-agent` with NDJSON streaming.
15
+ * Extends CliProvider with Cursor-specific event mapping, arg building,
16
+ * workspace trust auto-retry, and SQLite DAG history.
17
+ */
18
+ export class CursorCliProvider extends CliProvider {
19
+ readonly id = "cursor";
20
+ readonly name = "Cursor";
21
+ readonly cliCommand = "cursor-agent";
22
+
23
+ async isAvailable(): Promise<boolean> {
24
+ try {
25
+ const cmd = process.platform === "win32" ? "where" : "which";
26
+ const proc = Bun.spawn([cmd, "cursor-agent"], { stdout: "pipe", stderr: "pipe" });
27
+ await proc.exited;
28
+ return proc.exitCode === 0;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ buildArgs(params: {
35
+ sessionId?: string;
36
+ message: string;
37
+ model?: string;
38
+ permissionMode?: string;
39
+ isResume: boolean;
40
+ }): string[] {
41
+ const args: string[] = [];
42
+
43
+ if (params.sessionId && params.isResume) {
44
+ args.push(`--resume=${params.sessionId}`);
45
+ }
46
+
47
+ args.push("-p", params.message);
48
+
49
+ if (!params.isResume && params.model) {
50
+ args.push("--model", params.model);
51
+ }
52
+
53
+ args.push("--output-format", "stream-json");
54
+
55
+ // Permission mode → CLI flags
56
+ const mode = params.permissionMode || "default";
57
+ if (mode === "bypassPermissions") {
58
+ args.push("-f");
59
+ }
60
+
61
+ return args;
62
+ }
63
+
64
+ mapEvent(raw: unknown, sessionId: string): ChatEvent[] {
65
+ return mapCursorEvent(raw, sessionId);
66
+ }
67
+
68
+ extractSessionId(raw: unknown): string | null {
69
+ const obj = raw as Record<string, unknown>;
70
+ if (obj?.type === "system" && obj?.subtype === "init") {
71
+ return (obj.session_id as string) || null;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ // Override listSessions to include Cursor's native history
77
+ override async listSessions(): Promise<SessionInfo[]> {
78
+ const inMemory = await super.listSessions();
79
+ try {
80
+ const native = await listCursorSessions(this.id);
81
+ // Merge: in-memory first, then native (deduplicated)
82
+ const seen = new Set(inMemory.map((s) => s.id));
83
+ const merged = [...inMemory];
84
+ for (const s of native) {
85
+ if (!seen.has(s.id)) merged.push(s);
86
+ }
87
+ return merged;
88
+ } catch {
89
+ return inMemory;
90
+ }
91
+ }
92
+
93
+ // Optional: load history from SQLite
94
+ async getMessages(sessionId: string): Promise<ChatMessage[]> {
95
+ const meta = this.sessions.get(sessionId);
96
+ return loadCursorHistory(sessionId, meta?.projectPath);
97
+ }
98
+
99
+ /** Cached models list with TTL from `cursor-agent --list-models` */
100
+ private modelsCache: { models: ModelOption[]; expiry: number } | null = null;
101
+ private static CACHE_TTL = 5 * 60 * 1000; // 5 min
102
+
103
+ async listModels(): Promise<ModelOption[]> {
104
+ if (this.modelsCache && Date.now() < this.modelsCache.expiry) {
105
+ return this.modelsCache.models;
106
+ }
107
+ try {
108
+ const proc = Bun.spawn(["cursor-agent", "--list-models"], {
109
+ stdout: "pipe",
110
+ stderr: "pipe",
111
+ });
112
+ const timeout = setTimeout(() => proc.kill(), 10_000);
113
+ const text = await new Response(proc.stdout).text();
114
+ clearTimeout(timeout);
115
+ await proc.exited;
116
+ const models: ModelOption[] = [];
117
+ for (const line of text.split("\n")) {
118
+ // Format: "model-id - Model Label" or "model-id - Model Label (current, default)"
119
+ const match = line.match(/^(\S+)\s+-\s+(.+?)(?:\s+\(.*\))?$/);
120
+ if (match?.[1] && match[2]) {
121
+ models.push({ value: match[1], label: match[2].trim() });
122
+ }
123
+ }
124
+ if (models.length > 0) {
125
+ this.modelsCache = { models, expiry: Date.now() + CursorCliProvider.CACHE_TTL };
126
+ }
127
+ return models;
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ // Workspace trust detection: log warning so user knows to re-run with --trust
134
+ protected override spawnProcess(args: string[], cwd: string): ChildProcess {
135
+ const proc = super.spawnProcess(args, cwd);
136
+
137
+ proc.stderr?.on("data", (data: Buffer) => {
138
+ const text = data.toString();
139
+ if (TRUST_PATTERNS.some((p) => p.test(text))) {
140
+ console.warn("[cursor] Workspace trust prompt detected. Re-run with bypassPermissions mode or add --trust flag.");
141
+ }
142
+ });
143
+
144
+ return proc;
145
+ }
146
+ }
@@ -167,7 +167,7 @@ export class MockProvider implements AIProvider {
167
167
  }
168
168
  }
169
169
 
170
- getMessages(sessionId: string): ChatMessage[] {
170
+ async getMessages(sessionId: string): Promise<ChatMessage[]> {
171
171
  return this.messageHistory.get(sessionId) ?? [];
172
172
  }
173
173
  }
@@ -8,4 +8,5 @@ export type {
8
8
  ToolApprovalHandler,
9
9
  UsageInfo,
10
10
  SendMessageOpts,
11
+ ModelOption,
11
12
  } from "../types/chat.ts";
@@ -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";
@@ -135,7 +135,8 @@ function startCleanupTimer(sessionId: string): void {
135
135
  * Standalone streaming loop — decoupled from WS message handler.
136
136
  * Runs independently so WS close does NOT kill the Claude query.
137
137
  */
138
- async function runStreamLoop(sessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
138
+ async function runStreamLoop(initialSessionId: string, providerId: string, content: string, permissionMode?: string): Promise<void> {
139
+ let sessionId = initialSessionId;
139
140
  const entry = activeSessions.get(sessionId);
140
141
  if (!entry) {
141
142
  console.error(`[chat] session=${sessionId} runStreamLoop: no entry — aborting`);
@@ -198,6 +199,23 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
198
199
  const ev = event as any;
199
200
  const evType = ev.type ?? "unknown";
200
201
 
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
+
201
219
  // System events (hook_started, init, etc.) → transition connecting → thinking
202
220
  // These indicate SDK has connected and is processing, but no content yet.
203
221
  if (evType === "system") {
@@ -479,17 +497,17 @@ export const chatWebSocket = {
479
497
 
480
498
  // Resume session in provider (can be slow on first call — sdkListSessions)
481
499
  const provider = providerRegistry.get(providerId);
482
- if (provider && "resumeSession" in provider) {
500
+ if (provider) {
483
501
  const t0 = Date.now();
484
- await (provider as any).resumeSession(sessionId);
502
+ await provider.resumeSession(sessionId);
485
503
  const elapsed = Date.now() - t0;
486
504
  if (elapsed > 500) {
487
505
  console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
488
506
  logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
489
507
  }
490
508
  }
491
- if (entry.projectPath && provider && "ensureProjectPath" in provider) {
492
- (provider as any).ensureProjectPath(sessionId, entry.projectPath);
509
+ if (entry.projectPath && provider?.ensureProjectPath) {
510
+ provider.ensureProjectPath(sessionId, entry.projectPath);
493
511
  }
494
512
 
495
513
  // Abort-and-replace: if already streaming, abort current query and wait for cleanup
@@ -518,9 +536,7 @@ export const chatWebSocket = {
518
536
  });
519
537
  } else if (parsed.type === "cancel") {
520
538
  const provider = providerRegistry.get(providerId);
521
- if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
522
- (provider as any).abortQuery(sessionId);
523
- }
539
+ provider?.abortQuery?.(sessionId);
524
540
  } else if (parsed.type === "approval_response") {
525
541
  const provider = providerRegistry.get(providerId);
526
542
  if (provider && typeof provider.resolveApproval === "function") {