@hienlh/ppm 0.8.87 → 0.8.89
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 +75 -40
- package/dist/web/assets/{_basePickBy-5PGDJbfF.js → _basePickBy-3Xe18azI.js} +1 -1
- package/dist/web/assets/{_baseUniq-BT4Ow4Kk.js → _baseUniq-Yy35llnn.js} +1 -1
- package/dist/web/assets/api-settings-Bid0NHuI.js +1 -0
- package/dist/web/assets/{arc-BAOivWpI.js → arc-B9n1Gvb5.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CFzkFKEL.js +1 -0
- package/dist/web/assets/{architectureDiagram-2XIMDMQ5-DWBCPMLF.js → architectureDiagram-2XIMDMQ5-DqAZP_F6.js} +1 -1
- package/dist/web/assets/{blockDiagram-WCTKOSBZ-TEF8Ally.js → blockDiagram-WCTKOSBZ-h3cDF2vI.js} +1 -1
- package/dist/web/assets/{browser-tab-DaHGm_0i.js → browser-tab-DSWumOSG.js} +1 -1
- package/dist/web/assets/{c4Diagram-IC4MRINW-dV22iAsY.js → c4Diagram-IC4MRINW--pF1r5lr.js} +1 -1
- package/dist/web/assets/channel-C2fMafck.js +1 -0
- package/dist/web/assets/chat-tab-Ccwf-c6M.js +8 -0
- package/dist/web/assets/{chunk-4BX2VUAB-D4tOov49.js → chunk-4BX2VUAB-C3aZvW7B.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-DJ6BynZ4.js → chunk-55IACEB6-D5cABeB9.js} +1 -1
- package/dist/web/assets/{chunk-7E7YKBS2-CiyUJxNI.js → chunk-7E7YKBS2-CkFGv6Zs.js} +1 -1
- package/dist/web/assets/{chunk-7R4GIKGN-BbIFzsIv.js → chunk-7R4GIKGN-Dvbyu4Zw.js} +2 -2
- package/dist/web/assets/{chunk-C72U2L5F-D21mS_6G.js → chunk-C72U2L5F-CtqKiH4q.js} +1 -1
- package/dist/web/assets/{chunk-EGIJ26TM-DzqmU2Z7.js → chunk-EGIJ26TM-Cpr87sBR.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-DXncblvW.js → chunk-FMBD7UC4-D23YVTOU.js} +1 -1
- package/dist/web/assets/{chunk-GEFDOKGD-BbQkJu8C.js → chunk-GEFDOKGD-tDjHsAUs.js} +1 -1
- package/dist/web/assets/chunk-GLR3WWYH-DBdWQ3zy.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-BBw_z0fW.js +1 -0
- package/dist/web/assets/{chunk-JSJVCQXG-23tyvw8k.js → chunk-JSJVCQXG-BBmymCjA.js} +1 -1
- package/dist/web/assets/{chunk-KX2RTZJC-sQ0o-39C.js → chunk-KX2RTZJC-DP36BDiU.js} +1 -1
- package/dist/web/assets/{chunk-KYZI473N-BcUZNnwd.js → chunk-KYZI473N-Djw13C-3.js} +1 -1
- package/dist/web/assets/{chunk-L3YUKLVL-C7qGJrfV.js → chunk-L3YUKLVL-HG_eMj_C.js} +1 -1
- package/dist/web/assets/{chunk-MX3YWQON-BpS_PtKp.js → chunk-MX3YWQON-C2UEioMs.js} +1 -1
- package/dist/web/assets/{chunk-NQ4KR5QH-wMgTlP7f.js → chunk-NQ4KR5QH-DXUTQ-BL.js} +1 -1
- package/dist/web/assets/{chunk-O4XLMI2P-JC6EGoUz.js → chunk-O4XLMI2P-BsUWb9d0.js} +1 -1
- package/dist/web/assets/{chunk-OZEHJAEY-BXhYx3nO.js → chunk-OZEHJAEY-rG0P22U9.js} +1 -1
- package/dist/web/assets/{chunk-PQ6SQG4A-D6BTbCQw.js → chunk-PQ6SQG4A-DX0xW7kO.js} +1 -1
- package/dist/web/assets/{chunk-PU5JKC2W-Dw8ClWch.js → chunk-PU5JKC2W-C7Gry6md.js} +1 -1
- package/dist/web/assets/chunk-QZHKN3VN-DFKFM_C1.js +1 -0
- package/dist/web/assets/{chunk-R5LLSJPH-CFwSJijQ.js → chunk-R5LLSJPH-CMY0PkRK.js} +1 -1
- package/dist/web/assets/{chunk-WL4C6EOR-DfofndiH.js → chunk-WL4C6EOR-CXuQvlyu.js} +1 -1
- package/dist/web/assets/{chunk-XIRO2GV7-Djlmrely.js → chunk-XIRO2GV7-DRJEb7Zb.js} +1 -1
- package/dist/web/assets/{chunk-XPW4576I-BPQQBakK.js → chunk-XPW4576I-BPEX8KhL.js} +1 -1
- package/dist/web/assets/{chunk-XZSTWKYB-DxAOx4hG.js → chunk-XZSTWKYB-Cb0iqycX.js} +1 -1
- package/dist/web/assets/{chunk-YBOYWFTD-CeU4Q-xC.js → chunk-YBOYWFTD-av5aeHLq.js} +1 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-Dp4Kk3Yb.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-D8IvcV_B.js +1 -0
- package/dist/web/assets/clone-B2hUek6n.js +1 -0
- package/dist/web/assets/code-editor-DLTcPb55.js +2 -0
- package/dist/web/assets/{cose-bilkent-S5V4N54A-B_AWZsOP.js → cose-bilkent-S5V4N54A-qudEiMCT.js} +1 -1
- package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-DUbHtTAS.js} +1 -1
- package/dist/web/assets/{dagre-Dbb5k38K.js → dagre-BFcnKyBF.js} +1 -1
- package/dist/web/assets/{dagre-KLK3FWXG-BH7aWGRP.js → dagre-KLK3FWXG-C3O-MTLf.js} +1 -1
- package/dist/web/assets/{database-viewer-DXk79Nel.js → database-viewer-BrpPlYG7.js} +1 -1
- package/dist/web/assets/{diagram-E7M64L7V-B1Qz70Do.js → diagram-E7M64L7V-DxPjK7_c.js} +1 -1
- package/dist/web/assets/{diagram-IFDJBPK2-k55eVqVU.js → diagram-IFDJBPK2-sqTog_XV.js} +1 -1
- package/dist/web/assets/{diagram-P4PSJMXO-BkfNRc9U.js → diagram-P4PSJMXO-hzmp0GHK.js} +1 -1
- package/dist/web/assets/diff-viewer-Dx96kcTu.js +4 -0
- package/dist/web/assets/{erDiagram-INFDFZHY-CKzVujYI.js → erDiagram-INFDFZHY-DLeYhAAT.js} +1 -1
- package/dist/web/assets/{flowDiagram-PKNHOUZH-DIqcTrDV.js → flowDiagram-PKNHOUZH-CRxlE9Sr.js} +1 -1
- package/dist/web/assets/{ganttDiagram-A5KZAMGK-D4v7ZbVE.js → ganttDiagram-A5KZAMGK-BdjmoMLS.js} +1 -1
- package/dist/web/assets/git-graph-CoN6voTp.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-CNlas3Rz.js +1 -0
- package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-BTXo57mF.js → gitGraphDiagram-K3NZZRJ6-BeHSX7kk.js} +1 -1
- package/dist/web/assets/{graphlib-BcsNnGcW.js → graphlib-Duh_bWLa.js} +1 -1
- package/dist/web/assets/index-CtbNK_ih.css +2 -0
- package/dist/web/assets/index-DRdx_Wqn.js +37 -0
- package/dist/web/assets/info-3K5VOQVL-BDzTLc11.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-ZZmpgc6t.js +2 -0
- package/dist/web/assets/{isEmpty-bnrF3Qbc.js → isEmpty-B9L-Ge-H.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-PHBUUO56-BOyvKMmB.js → ishikawaDiagram-PHBUUO56-Cu0Rt1Ok.js} +1 -1
- package/dist/web/assets/{journeyDiagram-4ABVD52K-ufoasAy6.js → journeyDiagram-4ABVD52K-CgDI-UG4.js} +1 -1
- package/dist/web/assets/{kanban-definition-K7BYSVSG-Bi0UTUeN.js → kanban-definition-K7BYSVSG-h4g10UHL.js} +1 -1
- package/dist/web/assets/keybindings-store-DHGoLYnP.js +1 -0
- package/dist/web/assets/{line-B78g-52T.js → line-B75-Rx70.js} +1 -1
- package/dist/web/assets/{linear-DP4mkX3m.js → linear-Bcjv9FQt.js} +1 -1
- package/dist/web/assets/{markdown-renderer-Brj8_LQM.js → markdown-renderer-BqsXIW9n.js} +5 -5
- package/dist/web/assets/{mermaid-parser.core-DMIWdgEW.js → mermaid-parser.core-8u2leTXI.js} +2 -2
- package/dist/web/assets/{mindmap-definition-YRQLILUH-BsfWvIoO.js → mindmap-definition-YRQLILUH-BaOBwb-W.js} +1 -1
- package/dist/web/assets/{ordinal-_K3x1fkz.js → ordinal-LFEjVtwQ.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-IVa5F-go.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-CvXHKAzp.js +1 -0
- package/dist/web/assets/{pieDiagram-SKSYHLDU-WP0XXw51.js → pieDiagram-SKSYHLDU-At5Kz0KK.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CwkTGmqy.js → postgres-viewer-Lw8xaGfc.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-337W2JSQ-FHMogtsh.js → quadrantDiagram-337W2JSQ-CdjGIDfw.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-Z-Tr5wtS.js +1 -0
- package/dist/web/assets/{requirementDiagram-Z7DCOOCP-BatTxyWb.js → requirementDiagram-Z7DCOOCP-B9F_Cx_p.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-ClJuW3Hv.js → sankeyDiagram-WA2Y5GQK-RolPi8bU.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-2WXFIKYE-ByxQqGgs.js → sequenceDiagram-2WXFIKYE-DM-tMAhx.js} +1 -1
- package/dist/web/assets/settings-tab-DDCC58we.js +1 -0
- package/dist/web/assets/{sqlite-viewer-CFYTwgA8.js → sqlite-viewer-DECA802J.js} +1 -1
- package/dist/web/assets/{stateDiagram-RAJIS63D-f8opcZNY.js → stateDiagram-RAJIS63D-C4EMl6jf.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-B-UjZch3.js +1 -0
- package/dist/web/assets/{tab-store-BJw7OCmy.js → tab-store--SlERlDs.js} +1 -1
- package/dist/web/assets/{terminal-tab-CCDLZA5Y.js → terminal-tab-DneNM6WP.js} +2 -2
- package/dist/web/assets/{timeline-definition-YZTLITO2-58BlOSf9.js → timeline-definition-YZTLITO2-A4PN_Efm.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-C9TYRE0k.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CNzekTN3.js → use-monaco-theme-CrtYAJMR.js} +1 -1
- package/dist/web/assets/{vennDiagram-LZ73GAT5-BOSy9ma9.js → vennDiagram-LZ73GAT5-ywK7LMaH.js} +1 -1
- package/dist/web/assets/{xychartDiagram-JWTSCODW-z5MVJauZ.js → xychartDiagram-JWTSCODW-DylHYNtJ.js} +1 -1
- package/dist/web/index.html +10 -11
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +155 -0
- package/docs/codebase-summary.md +261 -95
- package/docs/project-changelog.md +38 -3
- package/docs/project-roadmap.md +2 -2
- package/docs/streaming-input-guide.md +267 -0
- package/docs/system-architecture.md +151 -0
- package/package.json +1 -1
- package/snapshot-state.md +1526 -0
- package/src/providers/claude-agent-sdk.ts +244 -102
- 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 +6 -0
- package/src/server/routes/chat.ts +14 -3
- package/src/server/routes/mcp.ts +84 -0
- package/src/server/routes/settings.ts +14 -0
- package/src/server/ws/chat.ts +127 -81
- package/src/services/account.service.ts +2 -2
- package/src/services/chat.service.ts +10 -15
- package/src/services/claude-usage.service.ts +2 -7
- package/src/services/db.service.ts +8 -0
- package/src/services/mcp-config.service.ts +111 -0
- package/src/types/api.ts +1 -1
- package/src/types/chat.ts +23 -2
- package/src/types/config.ts +33 -11
- package/src/types/mcp.ts +47 -0
- package/src/utils/ndjson-line-parser.ts +36 -0
- package/src/web/components/chat/chat-history-bar.tsx +48 -29
- package/src/web/components/chat/chat-tab.tsx +29 -24
- package/src/web/components/chat/message-input.tsx +64 -5
- package/src/web/components/chat/provider-selector.tsx +150 -0
- package/src/web/components/chat/session-picker.tsx +3 -1
- package/src/web/components/chat/usage-badge.tsx +58 -8
- package/src/web/components/settings/ai-settings-section.tsx +196 -137
- package/src/web/components/settings/mcp-server-dialog.tsx +208 -0
- package/src/web/components/settings/mcp-settings-section.tsx +143 -0
- package/src/web/components/settings/settings-tab.tsx +5 -2
- package/src/web/hooks/use-chat.ts +32 -15
- package/src/web/lib/api-mcp.ts +38 -0
- package/test-tokens.mjs +212 -0
- package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
- package/dist/web/assets/api-settings-Bx1GaNmQ.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +0 -1
- package/dist/web/assets/channel-wrd-NHWf.js +0 -1
- package/dist/web/assets/chat-tab-BDYE0KHF.js +0 -8
- package/dist/web/assets/chunk-GLR3WWYH-CzYx4w-r.js +0 -2
- package/dist/web/assets/chunk-HHEYEP7N-HRhYy3kG.js +0 -1
- package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +0 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-lse8oZoJ.js +0 -1
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-CxkwuInd.js +0 -1
- package/dist/web/assets/clone-LRxlvnMj.js +0 -1
- package/dist/web/assets/code-editor-DTA3c9Y8.js +0 -2
- package/dist/web/assets/diff-viewer-HhIcsOQE.js +0 -4
- package/dist/web/assets/git-graph-CQtWu8yE.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +0 -1
- package/dist/web/assets/index-CgQXpBb_.css +0 -2
- package/dist/web/assets/index-DEeeRoka.js +0 -37
- package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +0 -1
- package/dist/web/assets/infoDiagram-LFFYTUFH-B1CX0pbC.js +0 -2
- package/dist/web/assets/input-BglMT33g.js +0 -1
- package/dist/web/assets/keybindings-store-1CJ7VX57.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +0 -1
- package/dist/web/assets/settings-tab-BDE1MsIh.js +0 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-DrxVDY9q.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +0 -1
- /package/dist/web/assets/{api-client-BfBM3I7n.js → api-client-BKIT_Qeg.js} +0 -0
- /package/dist/web/assets/{array-B9UHiPd-.js → array-DqLCdDFv.js} +0 -0
- /package/dist/web/assets/{chevron-right-DeV0ehiG.js → chevron-right-CHnjJt4E.js} +0 -0
- /package/dist/web/assets/{columns-2-DpsNbZOc.js → columns-2-DbesTfa7.js} +0 -0
- /package/dist/web/assets/{cytoscape.esm-BW-DbntU.js → cytoscape.esm-CWPXKqbJ.js} +0 -0
- /package/dist/web/assets/{defaultLocale-5eAKkKJC.js → defaultLocale-CrJzLgRD.js} +0 -0
- /package/dist/web/assets/{dist-lF8CoYII.js → dist-CALwEtco.js} +0 -0
- /package/dist/web/assets/{dist-CSJdAyA9.js → dist-Cep75xXf.js} +0 -0
- /package/dist/web/assets/{dist-DylI9XxN.js → dist-DGDPTxs1.js} +0 -0
- /package/dist/web/assets/{init-DlZdxViB.js → init-C0r9Gk5G.js} +0 -0
- /package/dist/web/assets/{isArrayLikeObject-B_v2FtYn.js → isArrayLikeObject-CGBoxvCD.js} +0 -0
- /package/dist/web/assets/{katex-Bqvo_ZG0.js → katex-DzXRfQ_m.js} +0 -0
- /package/dist/web/assets/{lib-BQ34Db2e.js → lib-BeaDXEkP.js} +0 -0
- /package/dist/web/assets/{math-069Z4SuC.js → math-y9zN1W-N.js} +0 -0
- /package/dist/web/assets/{path-6uRLdFF7.js → path-DIKpVbHL.js} +0 -0
- /package/dist/web/assets/{preload-helper-uTix4PVD.js → preload-helper-Bf_JiD2A.js} +0 -0
- /package/dist/web/assets/{react-ER-4DN55.js → react-SKk5z-bm.js} +0 -0
- /package/dist/web/assets/{rough.esm-JX0wREDd.js → rough.esm-nHaDi0Kw.js} +0 -0
- /package/dist/web/assets/{src-BqX54PbV.js → src-Dw4QhedI.js} +0 -0
- /package/dist/web/assets/{table-C7X5UAEI.js → table-CQVQM2SB.js} +0 -0
- /package/dist/web/assets/{tag-CCtdV063.js → tag-Q2dZiSPX.js} +0 -0
- /package/dist/web/assets/{utils-BNytJOb1.js → utils-DMiycH3O.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 { mcpRoutes } from "./routes/mcp.ts";
|
|
17
18
|
import { browserPreviewRoutes } from "./routes/browser-preview.ts";
|
|
18
19
|
import { initAdapters } from "../services/database/init-adapters.ts";
|
|
19
20
|
import { terminalWebSocket } from "./ws/terminal.ts";
|
|
@@ -139,6 +140,7 @@ app.route("/api/fs", fsBrowseRoutes);
|
|
|
139
140
|
|
|
140
141
|
// API routes
|
|
141
142
|
app.route("/api/settings", settingsRoutes);
|
|
143
|
+
app.route("/api/settings/mcp", mcpRoutes);
|
|
142
144
|
app.route("/api/tunnel", tunnelRoutes);
|
|
143
145
|
app.route("/api/push", pushRoutes);
|
|
144
146
|
app.route("/api/projects", projectRoutes);
|
|
@@ -174,6 +176,10 @@ export async function startServer(options: {
|
|
|
174
176
|
|
|
175
177
|
await setupLogFile();
|
|
176
178
|
|
|
179
|
+
// Bootstrap CLI providers (checks binary availability)
|
|
180
|
+
const { bootstrapProviders } = await import("../providers/registry.ts");
|
|
181
|
+
await bootstrapProviders();
|
|
182
|
+
|
|
177
183
|
// Check if port is already in use before spawning supervisor
|
|
178
184
|
const portInUse = await new Promise<boolean>((resolve) => {
|
|
179
185
|
const net = require("node:net") as typeof import("node:net");
|
|
@@ -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 {
|
|
@@ -179,9 +192,7 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
|
|
|
179
192
|
});
|
|
180
193
|
// Store fork source so WS handler knows to use forkSession on first message
|
|
181
194
|
const provider = providerRegistry.get(providerId);
|
|
182
|
-
|
|
183
|
-
(provider as any).setForkSource(session.id, sourceId);
|
|
184
|
-
}
|
|
195
|
+
provider?.setForkSource?.(session.id, sourceId);
|
|
185
196
|
return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
|
|
186
197
|
} catch (e) {
|
|
187
198
|
return c.json(err((e as Error).message), 500);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { mcpConfigService } from "../../services/mcp-config.service";
|
|
6
|
+
import { validateMcpName, validateMcpConfig, type McpServerConfig } from "../../types/mcp";
|
|
7
|
+
import { ok, err } from "../../types/api";
|
|
8
|
+
|
|
9
|
+
export const mcpRoutes = new Hono();
|
|
10
|
+
|
|
11
|
+
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
|
12
|
+
|
|
13
|
+
function readClaudeMcpServers(): Record<string, unknown> | null {
|
|
14
|
+
if (!existsSync(CLAUDE_CONFIG)) return null;
|
|
15
|
+
try {
|
|
16
|
+
const data = JSON.parse(readFileSync(CLAUDE_CONFIG, "utf-8"));
|
|
17
|
+
return data.mcpServers ?? null;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// GET / — list all (auto-imports from ~/.claude.json on first access if table empty)
|
|
22
|
+
mcpRoutes.get("/", (c) => {
|
|
23
|
+
let servers = mcpConfigService.listWithMeta();
|
|
24
|
+
if (servers.length === 0) {
|
|
25
|
+
const claudeServers = readClaudeMcpServers();
|
|
26
|
+
if (claudeServers && Object.keys(claudeServers).length > 0) {
|
|
27
|
+
mcpConfigService.bulkImport(claudeServers as Record<string, McpServerConfig>);
|
|
28
|
+
servers = mcpConfigService.listWithMeta();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return c.json(ok(servers));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// GET /import/preview — show what would be imported
|
|
35
|
+
mcpRoutes.get("/import/preview", (c) => {
|
|
36
|
+
const servers = readClaudeMcpServers();
|
|
37
|
+
if (!servers) return c.json(ok({ available: false, servers: {} }));
|
|
38
|
+
return c.json(ok({ available: true, servers }));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// POST /import — import from ~/.claude.json
|
|
42
|
+
mcpRoutes.post("/import", (c) => {
|
|
43
|
+
const servers = readClaudeMcpServers();
|
|
44
|
+
if (!servers) return c.json(err("~/.claude.json not found or has no mcpServers"), 404);
|
|
45
|
+
const result = mcpConfigService.bulkImport(servers as Record<string, McpServerConfig>);
|
|
46
|
+
return c.json(ok(result));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// GET /:name — single server
|
|
50
|
+
mcpRoutes.get("/:name", (c) => {
|
|
51
|
+
const config = mcpConfigService.get(c.req.param("name"));
|
|
52
|
+
if (!config) return c.json(err("Server not found"), 404);
|
|
53
|
+
return c.json(ok(config));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// POST / — add new server
|
|
57
|
+
mcpRoutes.post("/", async (c) => {
|
|
58
|
+
const { name, config } = await c.req.json();
|
|
59
|
+
const nameErr = validateMcpName(name);
|
|
60
|
+
if (nameErr) return c.json(err(nameErr), 400);
|
|
61
|
+
const configErrs = validateMcpConfig(config);
|
|
62
|
+
if (configErrs.length) return c.json(err(configErrs.join("; ")), 400);
|
|
63
|
+
if (mcpConfigService.exists(name)) return c.json(err("Server already exists"), 409);
|
|
64
|
+
mcpConfigService.set(name, config);
|
|
65
|
+
return c.json(ok({ name }), 201);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// PUT /:name — update server config
|
|
69
|
+
mcpRoutes.put("/:name", async (c) => {
|
|
70
|
+
const name = c.req.param("name");
|
|
71
|
+
if (!mcpConfigService.exists(name)) return c.json(err("Server not found"), 404);
|
|
72
|
+
const config = await c.req.json();
|
|
73
|
+
const configErrs = validateMcpConfig(config);
|
|
74
|
+
if (configErrs.length) return c.json(err(configErrs.join("; ")), 400);
|
|
75
|
+
mcpConfigService.set(name, config);
|
|
76
|
+
return c.json(ok({ name }));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// DELETE /:name — remove server
|
|
80
|
+
mcpRoutes.delete("/:name", (c) => {
|
|
81
|
+
const removed = mcpConfigService.remove(c.req.param("name"));
|
|
82
|
+
if (!removed) return c.json(err("Server not found"), 404);
|
|
83
|
+
return c.json(ok(true));
|
|
84
|
+
});
|
|
@@ -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
|
@@ -23,7 +23,6 @@ type ChatWsSocket = {
|
|
|
23
23
|
interface SessionEntry {
|
|
24
24
|
providerId: string;
|
|
25
25
|
clients: Set<ChatWsSocket>;
|
|
26
|
-
abort?: AbortController;
|
|
27
26
|
projectPath?: string;
|
|
28
27
|
projectName?: string;
|
|
29
28
|
pingIntervals: Map<ChatWsSocket, ReturnType<typeof setInterval>>;
|
|
@@ -33,6 +32,8 @@ interface SessionEntry {
|
|
|
33
32
|
turnEvents: unknown[];
|
|
34
33
|
streamPromise?: Promise<void>;
|
|
35
34
|
permissionMode?: string;
|
|
35
|
+
/** Whether the persistent event consumer loop is running */
|
|
36
|
+
isStreamingActive: boolean;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/** Tracks active sessions — persists even when FE disconnects */
|
|
@@ -118,42 +119,46 @@ function clearClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
/** Start cleanup timer —
|
|
122
|
-
function startCleanupTimer(sessionId: string): void {
|
|
122
|
+
/** Start cleanup timer — called when no FE connected. Urgent mode (30s) for orphaned streaming sessions. */
|
|
123
|
+
function startCleanupTimer(sessionId: string, urgent = false): void {
|
|
123
124
|
const entry = activeSessions.get(sessionId);
|
|
124
125
|
if (!entry) return;
|
|
125
126
|
if (entry.cleanupTimer) clearTimeout(entry.cleanupTimer);
|
|
127
|
+
const delay = urgent ? 30_000 : CLEANUP_TIMEOUT_MS;
|
|
126
128
|
entry.cleanupTimer = setTimeout(() => {
|
|
127
129
|
console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
|
|
128
130
|
logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
|
|
131
|
+
// Close streaming session in provider
|
|
132
|
+
const provider = providerRegistry.get(entry.providerId);
|
|
133
|
+
if (provider && "closeStreamingSession" in provider) {
|
|
134
|
+
(provider as any).closeStreamingSession(sessionId);
|
|
135
|
+
}
|
|
129
136
|
for (const interval of entry.pingIntervals.values()) clearInterval(interval);
|
|
130
137
|
entry.pingIntervals.clear();
|
|
131
138
|
activeSessions.delete(sessionId);
|
|
132
|
-
},
|
|
139
|
+
}, delay);
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
143
|
+
* Persistent event consumer — runs for the entire session lifetime.
|
|
144
|
+
* First message creates the query; follow-ups push into the provider's
|
|
145
|
+
* message channel. Events from ALL turns flow through this single loop.
|
|
138
146
|
*/
|
|
139
|
-
async function
|
|
147
|
+
async function startSessionConsumer(sessionId: string, providerId: string, content: string, permissionMode?: string, images?: Array<{ data: string; mediaType: string }>): Promise<void> {
|
|
140
148
|
const entry = activeSessions.get(sessionId);
|
|
141
149
|
if (!entry) {
|
|
142
|
-
console.error(`[chat] session=${sessionId}
|
|
150
|
+
console.error(`[chat] session=${sessionId} startSessionConsumer: no entry — aborting`);
|
|
143
151
|
return;
|
|
144
152
|
}
|
|
145
|
-
|
|
146
|
-
console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
|
|
153
|
+
console.log(`[chat] session=${sessionId} startSessionConsumer started (clients=${entry.clients.size})`);
|
|
147
154
|
|
|
148
|
-
|
|
149
|
-
entry.abort = abortController;
|
|
155
|
+
entry.isStreamingActive = true;
|
|
150
156
|
entry.pendingApprovalEvent = undefined;
|
|
151
157
|
entry.turnEvents = [];
|
|
152
158
|
setPhase(sessionId, "connecting");
|
|
153
159
|
|
|
154
160
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
155
161
|
let lastContextWindowPct: number | undefined;
|
|
156
|
-
let doneEmitted = false;
|
|
157
162
|
|
|
158
163
|
try {
|
|
159
164
|
const userPreview = content.slice(0, 200);
|
|
@@ -162,12 +167,12 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
162
167
|
|
|
163
168
|
let eventCount = 0;
|
|
164
169
|
let firstEventReceived = false;
|
|
165
|
-
|
|
170
|
+
let startTime = Date.now();
|
|
166
171
|
|
|
167
172
|
// Heartbeat: while waiting for first response, send elapsed time every 5s
|
|
168
173
|
const CONNECTION_TIMEOUT_S = 120;
|
|
169
174
|
heartbeat = setInterval(() => {
|
|
170
|
-
if (firstEventReceived
|
|
175
|
+
if (firstEventReceived) {
|
|
171
176
|
clearInterval(heartbeat);
|
|
172
177
|
return;
|
|
173
178
|
}
|
|
@@ -186,27 +191,40 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
186
191
|
type: "error",
|
|
187
192
|
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
193
|
});
|
|
189
|
-
abortController.abort();
|
|
190
194
|
return;
|
|
191
195
|
}
|
|
192
|
-
// Heartbeat uses broadcast() directly — NOT setPhase() (same-phase guard would skip elapsed updates)
|
|
193
196
|
broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
|
|
194
197
|
}, 5_000);
|
|
195
198
|
|
|
196
|
-
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
|
|
197
|
-
if (abortController.signal.aborted) break;
|
|
199
|
+
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode, images })) {
|
|
198
200
|
eventCount++;
|
|
199
201
|
const ev = event as any;
|
|
200
202
|
const evType = ev.type ?? "unknown";
|
|
201
203
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
+
// Session ID migrated: CLI provider assigned a different ID than PPM generated.
|
|
205
|
+
// Migrate activeSessions key so all subsequent events use the real ID.
|
|
206
|
+
if (evType === "session_migrated") {
|
|
207
|
+
const { oldSessionId, newSessionId } = ev;
|
|
208
|
+
const migrated = activeSessions.get(oldSessionId);
|
|
209
|
+
if (migrated) {
|
|
210
|
+
activeSessions.delete(oldSessionId);
|
|
211
|
+
activeSessions.set(newSessionId, migrated);
|
|
212
|
+
sessionId = newSessionId; // update local ref for subsequent setPhase/broadcast calls
|
|
213
|
+
// Notify frontend to update its sessionId state
|
|
214
|
+
broadcast(newSessionId, { type: "session_migrated", oldSessionId, newSessionId });
|
|
215
|
+
console.log(`[chat] session migrated: ${oldSessionId} → ${newSessionId}`);
|
|
216
|
+
logSessionEvent(newSessionId, "INFO", `Session ID migrated from ${oldSessionId}`);
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// System events → transition connecting → thinking
|
|
204
222
|
if (evType === "system") {
|
|
205
223
|
if (!firstEventReceived) {
|
|
206
224
|
if (heartbeat) clearInterval(heartbeat);
|
|
207
225
|
setPhase(sessionId, "thinking");
|
|
208
226
|
}
|
|
209
|
-
continue;
|
|
227
|
+
continue;
|
|
210
228
|
}
|
|
211
229
|
|
|
212
230
|
// First content event — stop heartbeat, transition phase
|
|
@@ -239,10 +257,11 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
239
257
|
console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
|
|
240
258
|
logSessionEvent(sessionId, "ERROR", errorDetail);
|
|
241
259
|
} else if (evType === "done") {
|
|
242
|
-
|
|
260
|
+
// Turn complete — transition to idle, clear buffer for next turn
|
|
243
261
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
244
262
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
245
|
-
|
|
263
|
+
|
|
264
|
+
// Fire-and-forget: fetch updated session title (DB title takes priority) + notification
|
|
246
265
|
sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
|
|
247
266
|
const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
|
|
248
267
|
const dbTitle = getSessionTitle(found?.sessionId ?? sessionId);
|
|
@@ -253,7 +272,6 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
253
272
|
if (session) session.title = title;
|
|
254
273
|
}
|
|
255
274
|
}).catch(() => {});
|
|
256
|
-
// Fire-and-forget notification broadcast (push + telegram)
|
|
257
275
|
import("../../services/notification.service.ts").then(({ notificationService }) => {
|
|
258
276
|
const project = entry.projectName || "Project";
|
|
259
277
|
const session = chatService.getSession(sessionId);
|
|
@@ -268,7 +286,6 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
268
286
|
}).catch(() => {});
|
|
269
287
|
} else if (evType === "approval_request") {
|
|
270
288
|
entry.pendingApprovalEvent = ev;
|
|
271
|
-
// Fire-and-forget notification for approval/question
|
|
272
289
|
import("../../services/notification.service.ts").then(({ notificationService }) => {
|
|
273
290
|
const project = entry.projectName || "Project";
|
|
274
291
|
const session = chatService.getSession(sessionId);
|
|
@@ -287,32 +304,40 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
287
304
|
|
|
288
305
|
// Buffer + broadcast content events
|
|
289
306
|
bufferAndBroadcast(sessionId, event);
|
|
307
|
+
|
|
308
|
+
// After "done", transition to idle + clear turn buffer for next turn
|
|
309
|
+
// Consumer loop continues — query waits for next message in generator
|
|
310
|
+
if (evType === "done") {
|
|
311
|
+
entry.turnEvents = [];
|
|
312
|
+
entry.pendingApprovalEvent = undefined;
|
|
313
|
+
setPhase(sessionId, "idle");
|
|
314
|
+
// Reset heartbeat tracking for next turn
|
|
315
|
+
firstEventReceived = false;
|
|
316
|
+
startTime = Date.now();
|
|
317
|
+
}
|
|
290
318
|
}
|
|
291
319
|
|
|
292
|
-
logSessionEvent(sessionId, "INFO", `
|
|
293
|
-
console.log(`[chat] session=${sessionId}
|
|
320
|
+
logSessionEvent(sessionId, "INFO", `Session consumer completed (${eventCount} events total)`);
|
|
321
|
+
console.log(`[chat] session=${sessionId} session consumer completed (${eventCount} events)`);
|
|
294
322
|
} catch (e) {
|
|
295
323
|
const errMsg = (e as Error).message;
|
|
296
324
|
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
297
|
-
|
|
298
|
-
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
299
|
-
}
|
|
325
|
+
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
300
326
|
} finally {
|
|
301
327
|
if (heartbeat) clearInterval(heartbeat);
|
|
302
|
-
|
|
303
|
-
if (!doneEmitted) {
|
|
304
|
-
bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
305
|
-
}
|
|
306
|
-
// 2. Clear buffer BEFORE setting phase to idle
|
|
328
|
+
entry.isStreamingActive = false;
|
|
307
329
|
entry.turnEvents = [];
|
|
308
|
-
// 3. Transition to idle
|
|
309
330
|
setPhase(sessionId, "idle");
|
|
310
|
-
// 4. Cleanup
|
|
311
|
-
entry.abort = undefined;
|
|
312
331
|
entry.pendingApprovalEvent = undefined;
|
|
332
|
+
// Close streaming session in provider
|
|
333
|
+
const provider = providerRegistry.get(entry.providerId);
|
|
334
|
+
if (provider && "closeStreamingSession" in provider) {
|
|
335
|
+
(provider as any).closeStreamingSession(sessionId);
|
|
336
|
+
}
|
|
313
337
|
if (entry.clients.size === 0) {
|
|
314
338
|
startCleanupTimer(sessionId);
|
|
315
339
|
}
|
|
340
|
+
console.log(`[chat] session=${sessionId} consumer loop ended`);
|
|
316
341
|
}
|
|
317
342
|
}
|
|
318
343
|
|
|
@@ -389,6 +414,7 @@ export const chatWebSocket = {
|
|
|
389
414
|
pingIntervals: new Map(),
|
|
390
415
|
phase: "idle",
|
|
391
416
|
turnEvents: [],
|
|
417
|
+
isStreamingActive: false,
|
|
392
418
|
};
|
|
393
419
|
activeSessions.set(sessionId, newEntry);
|
|
394
420
|
setupClientPing(newEntry, ws);
|
|
@@ -439,7 +465,7 @@ export const chatWebSocket = {
|
|
|
439
465
|
if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
|
|
440
466
|
const newEntry: SessionEntry = {
|
|
441
467
|
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
442
|
-
pingIntervals: new Map(), phase: "idle", turnEvents: [],
|
|
468
|
+
pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
|
|
443
469
|
};
|
|
444
470
|
activeSessions.set(sessionId, newEntry);
|
|
445
471
|
setupClientPing(newEntry, ws);
|
|
@@ -476,57 +502,76 @@ export const chatWebSocket = {
|
|
|
476
502
|
ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
|
|
477
503
|
return;
|
|
478
504
|
}
|
|
505
|
+
// Validate image payload
|
|
506
|
+
if (parsed.images?.length) {
|
|
507
|
+
if (parsed.images.length > 5) {
|
|
508
|
+
ws.send(JSON.stringify({ type: "error", message: "Max 5 images per message" }));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const MAX_BASE64_SIZE = 7_000_000; // ~5MB decoded
|
|
512
|
+
const SUPPORTED_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
513
|
+
for (const img of parsed.images) {
|
|
514
|
+
if (img.data.length > MAX_BASE64_SIZE) {
|
|
515
|
+
ws.send(JSON.stringify({ type: "error", message: "Image too large (max 5MB)" }));
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (!SUPPORTED_TYPES.has(img.mediaType)) {
|
|
519
|
+
ws.send(JSON.stringify({ type: "error", message: `Unsupported image type: ${img.mediaType}` }));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
479
524
|
// Store permission mode — sticky for this session
|
|
480
525
|
if (parsed.permissionMode) {
|
|
481
526
|
entry.permissionMode = parsed.permissionMode;
|
|
482
527
|
}
|
|
483
528
|
|
|
484
|
-
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
485
529
|
const provider = providerRegistry.get(providerId);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
|
|
492
|
-
|
|
530
|
+
|
|
531
|
+
if (!entry.isStreamingActive) {
|
|
532
|
+
// First message or post-crash recovery: start persistent consumer
|
|
533
|
+
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
534
|
+
if (provider && "resumeSession" in provider) {
|
|
535
|
+
const t0 = Date.now();
|
|
536
|
+
await (provider as any).resumeSession(sessionId);
|
|
537
|
+
const elapsed = Date.now() - t0;
|
|
538
|
+
if (elapsed > 500) {
|
|
539
|
+
console.warn(`[chat] session=${sessionId} resumeSession took ${elapsed}ms`);
|
|
540
|
+
logSessionEvent(sessionId, "PERF", `resumeSession took ${elapsed}ms`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (entry.projectPath && provider && "ensureProjectPath" in provider) {
|
|
544
|
+
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
493
545
|
}
|
|
494
|
-
}
|
|
495
|
-
if (entry.projectPath && provider && "ensureProjectPath" in provider) {
|
|
496
|
-
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
497
|
-
}
|
|
498
546
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
entry.
|
|
503
|
-
|
|
504
|
-
|
|
547
|
+
entry.turnEvents = [];
|
|
548
|
+
setPhase(sessionId, "initializing");
|
|
549
|
+
|
|
550
|
+
const permMode = entry.permissionMode;
|
|
551
|
+
const msgImages = parsed.type === "message" ? parsed.images : undefined;
|
|
552
|
+
entry.streamPromise = new Promise<void>((resolve) => {
|
|
553
|
+
setTimeout(() => {
|
|
554
|
+
startSessionConsumer(sessionId, providerId, parsed.content, permMode, msgImages).then(resolve, resolve);
|
|
555
|
+
}, 0);
|
|
556
|
+
});
|
|
557
|
+
} else {
|
|
558
|
+
// Follow-up: push into existing generator via provider
|
|
559
|
+
if (provider && "pushMessage" in provider && parsed.type === "message") {
|
|
560
|
+
(provider as any).pushMessage(sessionId, parsed.content, {
|
|
561
|
+
priority: parsed.priority ?? 'next',
|
|
562
|
+
images: parsed.images,
|
|
563
|
+
});
|
|
505
564
|
}
|
|
506
|
-
//
|
|
507
|
-
entry =
|
|
508
|
-
|
|
565
|
+
// Clear turn events for new turn display + transition phase
|
|
566
|
+
entry.turnEvents = [];
|
|
567
|
+
entry.pendingApprovalEvent = undefined;
|
|
568
|
+
setPhase(sessionId, "thinking");
|
|
569
|
+
console.log(`[chat] session=${sessionId} follow-up pushed to generator`);
|
|
509
570
|
}
|
|
510
|
-
|
|
511
|
-
// Reset for new query
|
|
512
|
-
entry.turnEvents = [];
|
|
513
|
-
setPhase(sessionId, "initializing");
|
|
514
|
-
|
|
515
|
-
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
516
|
-
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
517
|
-
const permMode = entry.permissionMode;
|
|
518
|
-
entry.streamPromise = new Promise<void>((resolve) => {
|
|
519
|
-
setTimeout(() => {
|
|
520
|
-
runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
|
|
521
|
-
}, 0);
|
|
522
|
-
});
|
|
523
571
|
} else if (parsed.type === "cancel") {
|
|
524
|
-
//
|
|
525
|
-
if (entry?.abort) entry.abort.abort();
|
|
572
|
+
// Fully teardown streaming session — user must resume to continue
|
|
526
573
|
const provider = providerRegistry.get(providerId);
|
|
527
|
-
|
|
528
|
-
(provider as any).abortQuery(sessionId);
|
|
529
|
-
}
|
|
574
|
+
provider?.abortQuery?.(sessionId);
|
|
530
575
|
} else if (parsed.type === "approval_response") {
|
|
531
576
|
const provider = providerRegistry.get(providerId);
|
|
532
577
|
if (provider && typeof provider.resolveApproval === "function") {
|
|
@@ -549,8 +594,9 @@ export const chatWebSocket = {
|
|
|
549
594
|
evictClient(entry, ws);
|
|
550
595
|
console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
|
|
551
596
|
|
|
552
|
-
if (entry.clients.size === 0
|
|
553
|
-
|
|
597
|
+
if (entry.clients.size === 0) {
|
|
598
|
+
// Use shorter timeout if streaming is still active (orphaned session — no FE to consume events)
|
|
599
|
+
startCleanupTimer(sessionId, entry.isStreamingActive);
|
|
554
600
|
}
|
|
555
601
|
},
|
|
556
602
|
};
|
|
@@ -139,7 +139,7 @@ class AccountService {
|
|
|
139
139
|
await this.refreshAccessToken(id, false);
|
|
140
140
|
return this.getWithTokens(id);
|
|
141
141
|
} catch (e) {
|
|
142
|
-
console.error(`[accounts] Pre-flight refresh failed for ${id}
|
|
142
|
+
console.error(`[accounts] Pre-flight refresh failed for ${id}: ${(e as Error).message ?? e}`);
|
|
143
143
|
return null;
|
|
144
144
|
}
|
|
145
145
|
}
|
|
@@ -709,7 +709,7 @@ class AccountService {
|
|
|
709
709
|
try {
|
|
710
710
|
await this.refreshAccessToken(acc.id, false);
|
|
711
711
|
} catch (e) {
|
|
712
|
-
console.error(`[accounts] Auto-refresh failed for ${acc.id}
|
|
712
|
+
console.error(`[accounts] Auto-refresh failed for ${acc.id}: ${(e as Error).message ?? e}`);
|
|
713
713
|
}
|
|
714
714
|
}
|
|
715
715
|
};
|