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