@hienlh/ppm 0.8.85 → 0.8.86
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/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +5 -187
- package/bun.lock +0 -5
- package/dist/web/assets/{_basePickBy-5PGDJbfF.js → _basePickBy-5eBmZ_lt.js} +1 -1
- package/dist/web/assets/{_baseUniq-BT4Ow4Kk.js → _baseUniq-DimLlN0y.js} +1 -1
- package/dist/web/assets/api-settings-CFw-lh5k.js +1 -0
- package/dist/web/assets/{arc-BAOivWpI.js → arc-D4SasZrA.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CJupe6q_.js +1 -0
- package/dist/web/assets/{architectureDiagram-2XIMDMQ5-DWBCPMLF.js → architectureDiagram-2XIMDMQ5-nv0WbM7d.js} +1 -1
- package/dist/web/assets/{blockDiagram-WCTKOSBZ-TEF8Ally.js → blockDiagram-WCTKOSBZ-C1XvYrb8.js} +1 -1
- package/dist/web/assets/browser-tab-CmsL5eny.js +1 -0
- package/dist/web/assets/{c4Diagram-IC4MRINW-dV22iAsY.js → c4Diagram-IC4MRINW-CygDrbWJ.js} +1 -1
- package/dist/web/assets/channel-DmKoFTd_.js +1 -0
- package/dist/web/assets/chat-tab-CFWsf13Z.js +7 -0
- package/dist/web/assets/{chunk-4BX2VUAB-D4tOov49.js → chunk-4BX2VUAB-C2FDgsgT.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-DJ6BynZ4.js → chunk-55IACEB6-jF4w6cat.js} +1 -1
- package/dist/web/assets/{chunk-7E7YKBS2-CiyUJxNI.js → chunk-7E7YKBS2-BVCECZFi.js} +1 -1
- package/dist/web/assets/{chunk-7R4GIKGN-BbIFzsIv.js → chunk-7R4GIKGN-DXTbeu5d.js} +2 -2
- package/dist/web/assets/{chunk-C72U2L5F-D21mS_6G.js → chunk-C72U2L5F-BaZqOsTs.js} +1 -1
- package/dist/web/assets/{chunk-EGIJ26TM-DzqmU2Z7.js → chunk-EGIJ26TM-Bky2tcH7.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-DXncblvW.js → chunk-FMBD7UC4-Cp4BK9A8.js} +1 -1
- package/dist/web/assets/{chunk-GEFDOKGD-BbQkJu8C.js → chunk-GEFDOKGD-BosFEH7G.js} +1 -1
- package/dist/web/assets/chunk-GLR3WWYH-BnP-hOp6.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-DKDPTPEZ.js +1 -0
- package/dist/web/assets/{chunk-JSJVCQXG-23tyvw8k.js → chunk-JSJVCQXG-H5Gbjsbr.js} +1 -1
- package/dist/web/assets/{chunk-KX2RTZJC-sQ0o-39C.js → chunk-KX2RTZJC-CWerSUwS.js} +1 -1
- package/dist/web/assets/{chunk-KYZI473N-BcUZNnwd.js → chunk-KYZI473N-FvwP7jUy.js} +1 -1
- package/dist/web/assets/{chunk-L3YUKLVL-C7qGJrfV.js → chunk-L3YUKLVL-D1PI_ORP.js} +1 -1
- package/dist/web/assets/{chunk-MX3YWQON-BpS_PtKp.js → chunk-MX3YWQON-C7Vzk_AI.js} +1 -1
- package/dist/web/assets/{chunk-NQ4KR5QH-wMgTlP7f.js → chunk-NQ4KR5QH-BceYBGYX.js} +1 -1
- package/dist/web/assets/{chunk-O4XLMI2P-JC6EGoUz.js → chunk-O4XLMI2P-WPtzgxql.js} +1 -1
- package/dist/web/assets/{chunk-OZEHJAEY-BXhYx3nO.js → chunk-OZEHJAEY-DlHXDeLY.js} +1 -1
- package/dist/web/assets/{chunk-PQ6SQG4A-D6BTbCQw.js → chunk-PQ6SQG4A-Ci_Prygb.js} +1 -1
- package/dist/web/assets/{chunk-PU5JKC2W-Dw8ClWch.js → chunk-PU5JKC2W-CO0zMN-z.js} +1 -1
- package/dist/web/assets/chunk-QZHKN3VN-C_wpI9wz.js +1 -0
- package/dist/web/assets/{chunk-R5LLSJPH-CFwSJijQ.js → chunk-R5LLSJPH-IAEEzfpM.js} +1 -1
- package/dist/web/assets/{chunk-WL4C6EOR-DfofndiH.js → chunk-WL4C6EOR-BLXalOgc.js} +1 -1
- package/dist/web/assets/{chunk-XIRO2GV7-Djlmrely.js → chunk-XIRO2GV7-Dx1Ri_p2.js} +1 -1
- package/dist/web/assets/{chunk-XPW4576I-BPQQBakK.js → chunk-XPW4576I-m9pPGKn7.js} +1 -1
- package/dist/web/assets/{chunk-XZSTWKYB-DxAOx4hG.js → chunk-XZSTWKYB-B_08ExbI.js} +1 -1
- package/dist/web/assets/{chunk-YBOYWFTD-CeU4Q-xC.js → chunk-YBOYWFTD-DqSOVcYe.js} +1 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-B1T5uY-F.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-xs5vI3xC.js +1 -0
- package/dist/web/assets/clone-CijCFRT5.js +1 -0
- package/dist/web/assets/code-editor-H_dAh_fJ.js +1 -0
- package/dist/web/assets/{cose-bilkent-S5V4N54A-B_AWZsOP.js → cose-bilkent-S5V4N54A-DlL82QHu.js} +1 -1
- package/dist/web/assets/{dagre-Dbb5k38K.js → dagre-BmVoh2At.js} +1 -1
- package/dist/web/assets/{dagre-KLK3FWXG-BH7aWGRP.js → dagre-KLK3FWXG-sDrRW9MQ.js} +1 -1
- package/dist/web/assets/database-viewer-DBzsgEJ8.js +1 -0
- package/dist/web/assets/{diagram-E7M64L7V-B1Qz70Do.js → diagram-E7M64L7V-ChnAhgni.js} +1 -1
- package/dist/web/assets/{diagram-IFDJBPK2-k55eVqVU.js → diagram-IFDJBPK2-DW1J1uJd.js} +1 -1
- package/dist/web/assets/{diagram-P4PSJMXO-BkfNRc9U.js → diagram-P4PSJMXO-CQ32hyG_.js} +1 -1
- package/dist/web/assets/diff-viewer-DzS-OnAR.js +4 -0
- package/dist/web/assets/dist-0Va_2L7G.js +16 -0
- package/dist/web/assets/dist-D9irYETY.js +41 -0
- package/dist/web/assets/{erDiagram-INFDFZHY-CKzVujYI.js → erDiagram-INFDFZHY-6CHo6nOw.js} +1 -1
- package/dist/web/assets/{flowDiagram-PKNHOUZH-DIqcTrDV.js → flowDiagram-PKNHOUZH-DroDiNT0.js} +1 -1
- package/dist/web/assets/{ganttDiagram-A5KZAMGK-D4v7ZbVE.js → ganttDiagram-A5KZAMGK-DP0QBh8w.js} +1 -1
- package/dist/web/assets/git-graph-D3C7F8o3.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-B0KvGQG8.js +1 -0
- package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-BTXo57mF.js → gitGraphDiagram-K3NZZRJ6-DvU3JGZn.js} +1 -1
- package/dist/web/assets/{graphlib-BcsNnGcW.js → graphlib-CQBb2thr.js} +1 -1
- package/dist/web/assets/index-CIkjfera.js +31 -0
- package/dist/web/assets/index-WKLuYsBY.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-1uJ6_hCm.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-DLA5Q-3y.js +2 -0
- package/dist/web/assets/input-CGp1nFIg.js +1 -0
- package/dist/web/assets/{isEmpty-bnrF3Qbc.js → isEmpty-B4kqZBtn.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-PHBUUO56-BOyvKMmB.js → ishikawaDiagram-PHBUUO56-46yibrV5.js} +1 -1
- package/dist/web/assets/{journeyDiagram-4ABVD52K-ufoasAy6.js → journeyDiagram-4ABVD52K-BcmRwjK-.js} +1 -1
- package/dist/web/assets/{kanban-definition-K7BYSVSG-Bi0UTUeN.js → kanban-definition-K7BYSVSG-B619K53y.js} +1 -1
- package/dist/web/assets/keybindings-store-BdaoLwSo.js +1 -0
- package/dist/web/assets/{line-B78g-52T.js → line-1gcO63_w.js} +1 -1
- package/dist/web/assets/{linear-DP4mkX3m.js → linear-DfRqDoVd.js} +1 -1
- package/dist/web/assets/markdown-renderer-DH49Zag7.js +69 -0
- package/dist/web/assets/{mermaid-parser.core-DMIWdgEW.js → mermaid-parser.core-XtjZQOeM.js} +2 -2
- package/dist/web/assets/{mindmap-definition-YRQLILUH-BsfWvIoO.js → mindmap-definition-YRQLILUH-CifOFo_q.js} +1 -1
- package/dist/web/assets/{ordinal-_K3x1fkz.js → ordinal-BJYw-iDX.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-34C4o9yj.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-D9ekKlh9.js +1 -0
- package/dist/web/assets/{pieDiagram-SKSYHLDU-WP0XXw51.js → pieDiagram-SKSYHLDU-BuHUh_fO.js} +1 -1
- package/dist/web/assets/postgres-viewer-B9FYk8sD.js +1 -0
- package/dist/web/assets/{quadrantDiagram-337W2JSQ-FHMogtsh.js → quadrantDiagram-337W2JSQ-Bau_hj6Z.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DEuXOXSD.js +1 -0
- package/dist/web/assets/{requirementDiagram-Z7DCOOCP-BatTxyWb.js → requirementDiagram-Z7DCOOCP-Cq2b-uwp.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-ClJuW3Hv.js → sankeyDiagram-WA2Y5GQK-DrdGQxWQ.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-2WXFIKYE-ByxQqGgs.js → sequenceDiagram-2WXFIKYE-qPxiTUcS.js} +1 -1
- package/dist/web/assets/settings-store-DWXGVHsE.js +2 -0
- package/dist/web/assets/settings-tab-D-q8pd-5.js +1 -0
- package/dist/web/assets/sqlite-viewer-CDqcTePw.js +1 -0
- package/dist/web/assets/{stateDiagram-RAJIS63D-f8opcZNY.js → stateDiagram-RAJIS63D-Dulj2oa8.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CAkzLlhk.js +1 -0
- package/dist/web/assets/tab-store-BPeiymiH.js +1 -0
- package/dist/web/assets/{terminal-tab-CCDLZA5Y.js → terminal-tab-wKgpSPAT.js} +2 -2
- package/dist/web/assets/{timeline-definition-YZTLITO2-58BlOSf9.js → timeline-definition-YZTLITO2-BWyDnCYq.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-nc7a1Ia1.js +1 -0
- package/dist/web/assets/use-monaco-theme-CCBTQ0S3.js +11 -0
- package/dist/web/assets/{vennDiagram-LZ73GAT5-BOSy9ma9.js → vennDiagram-LZ73GAT5-B9Iv2bNV.js} +1 -1
- package/dist/web/assets/{xychartDiagram-JWTSCODW-z5MVJauZ.js → xychartDiagram-JWTSCODW-ChXcMzBQ.js} +1 -1
- package/dist/web/index.html +11 -12
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +7 -232
- package/docs/codebase-summary.md +3 -9
- package/docs/design-guidelines.md +0 -21
- package/docs/project-changelog.md +1 -115
- package/docs/project-roadmap.md +19 -41
- package/docs/system-architecture.md +15 -212
- package/package.json +2 -3
- package/src/cli/commands/autostart.ts +1 -1
- package/src/cli/commands/restart.ts +1 -9
- package/src/cli/commands/status.ts +0 -19
- package/src/index.ts +3 -2
- package/src/providers/claude-agent-sdk.ts +31 -94
- package/src/providers/mock-provider.ts +1 -6
- package/src/server/index.ts +166 -38
- package/src/server/routes/chat.ts +3 -52
- package/src/server/routes/project-scoped.ts +0 -2
- package/src/server/routes/proxy.ts +53 -46
- package/src/server/routes/tunnel.ts +32 -0
- package/src/server/ws/chat.ts +146 -207
- package/src/services/account-selector.service.ts +8 -16
- package/src/services/account.service.ts +13 -19
- package/src/services/claude-usage.service.ts +11 -48
- package/src/services/cloud.service.ts +6 -10
- package/src/services/db.service.ts +6 -111
- package/src/services/port-tunnel.service.ts +97 -0
- package/src/services/proxy.service.ts +19 -4
- package/src/services/supervisor.ts +25 -285
- package/src/types/api.ts +1 -9
- package/src/types/chat.ts +1 -3
- package/src/web/app.tsx +35 -41
- package/src/web/components/browser/browser-tab.tsx +97 -106
- package/src/web/components/chat/chat-history-bar.tsx +6 -72
- package/src/web/components/chat/chat-tab.tsx +16 -32
- package/src/web/components/chat/message-input.tsx +13 -107
- package/src/web/components/chat/message-list.tsx +15 -27
- package/src/web/components/chat/session-picker.tsx +31 -78
- package/src/web/components/chat/usage-badge.tsx +1 -11
- package/src/web/components/editor/code-editor.tsx +26 -36
- package/src/web/components/layout/command-palette.tsx +1 -3
- package/src/web/components/layout/editor-panel.tsx +18 -162
- package/src/web/components/layout/panel-layout.tsx +1 -17
- package/src/web/components/settings/proxy-settings-section.tsx +42 -40
- package/src/web/hooks/use-chat.ts +201 -211
- package/src/web/hooks/use-global-keybindings.ts +2 -25
- package/src/web/hooks/use-server-reload.ts +0 -9
- package/src/web/hooks/use-url-sync.ts +21 -173
- package/src/web/stores/keybindings-store.ts +0 -1
- package/src/web/stores/panel-store.ts +19 -73
- package/src/web/stores/panel-utils.ts +3 -145
- package/dist/web/assets/api-settings-Bx1GaNmQ.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +0 -1
- package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
- package/dist/web/assets/browser-tab-DaHGm_0i.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/chevron-right-DeV0ehiG.js +0 -1
- 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/csv-preview-DLqYtXxt.js +0 -10
- package/dist/web/assets/database-viewer-DXk79Nel.js +0 -1
- package/dist/web/assets/diff-viewer-HhIcsOQE.js +0 -4
- package/dist/web/assets/dist-DylI9XxN.js +0 -13
- package/dist/web/assets/dist-lF8CoYII.js +0 -41
- 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/lib-BQ34Db2e.js +0 -4
- package/dist/web/assets/markdown-renderer-Brj8_LQM.js +0 -69
- package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +0 -1
- package/dist/web/assets/postgres-viewer-CwkTGmqy.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +0 -1
- package/dist/web/assets/react-dom-Bpkvzu3U.js +0 -1
- package/dist/web/assets/settings-tab-BDE1MsIh.js +0 -1
- package/dist/web/assets/sqlite-viewer-CFYTwgA8.js +0 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-DrxVDY9q.js +0 -1
- package/dist/web/assets/tab-store-BJw7OCmy.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +0 -1
- package/dist/web/assets/use-monaco-theme-CNzekTN3.js +0 -11
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/src/server/routes/browser-preview.ts +0 -159
- package/src/server/routes/workspace.ts +0 -35
- package/src/services/cloud-ws.service.ts +0 -227
- package/src/web/components/chat/account-rotation-settings.tsx +0 -163
- package/src/web/components/chat/chat-welcome.tsx +0 -148
- package/src/web/components/editor/csv-preview.tsx +0 -228
- package/src/web/components/editor/editor-breadcrumb.tsx +0 -216
- package/src/web/components/editor/editor-toolbar.tsx +0 -74
- package/src/web/components/shared/connection-lost-overlay.tsx +0 -89
- package/src/web/hooks/use-voice-input.ts +0 -111
- package/src/web/lib/csv-parser.ts +0 -134
- package/src/web/stores/connection-store.ts +0 -39
- package/test-tokens.mjs +0 -212
- /package/dist/web/assets/{api-client-BfBM3I7n.js → api-client-DOElml5u.js} +0 -0
- /package/dist/web/assets/{array-B9UHiPd-.js → array-CYkMkqnU.js} +0 -0
- /package/dist/web/assets/{columns-2-DpsNbZOc.js → columns-2-ChOTgl3e.js} +0 -0
- /package/dist/web/assets/{cytoscape.esm-BW-DbntU.js → cytoscape.esm-HeHO0VhB.js} +0 -0
- /package/dist/web/assets/{defaultLocale-5eAKkKJC.js → defaultLocale-Beh6XjaL.js} +0 -0
- /package/dist/web/assets/{dist-CSJdAyA9.js → dist-BUYzeuKe.js} +0 -0
- /package/dist/web/assets/{init-DlZdxViB.js → init-Rr1s_RiX.js} +0 -0
- /package/dist/web/assets/{isArrayLikeObject-B_v2FtYn.js → isArrayLikeObject-BB-mzMLb.js} +0 -0
- /package/dist/web/assets/{katex-Bqvo_ZG0.js → katex-CKoArbIw.js} +0 -0
- /package/dist/web/assets/{math-069Z4SuC.js → math-B7b0HgJF.js} +0 -0
- /package/dist/web/assets/{path-6uRLdFF7.js → path-BAQ3hXlG.js} +0 -0
- /package/dist/web/assets/{preload-helper-uTix4PVD.js → preload-helper-DeiOTZKJ.js} +0 -0
- /package/dist/web/assets/{react-ER-4DN55.js → react-Dev-wu-s.js} +0 -0
- /package/dist/web/assets/{rough.esm-JX0wREDd.js → rough.esm-Dwml_la6.js} +0 -0
- /package/dist/web/assets/{src-BqX54PbV.js → src-B_cC68fH.js} +0 -0
- /package/dist/web/assets/{table-C7X5UAEI.js → table-COiJDPRA.js} +0 -0
- /package/dist/web/assets/{tag-CCtdV063.js → tag-LMq02LfE.js} +0 -0
- /package/dist/web/assets/{utils-BNytJOb1.js → utils-btZ8C8-R.js} +0 -0
package/src/server/ws/chat.ts
CHANGED
|
@@ -3,16 +3,10 @@ import { providerRegistry } from "../../providers/registry.ts";
|
|
|
3
3
|
import { resolveProjectPath } from "../helpers/resolve-project.ts";
|
|
4
4
|
import { logSessionEvent } from "../../services/session-log.service.ts";
|
|
5
5
|
import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
-
import {
|
|
7
|
-
import type { ChatWsClientMessage, SessionPhase } from "../../types/api.ts";
|
|
6
|
+
import type { ChatWsClientMessage } from "../../types/api.ts";
|
|
8
7
|
|
|
9
8
|
const PING_INTERVAL_MS = 15_000; // 15s keepalive
|
|
10
9
|
const CLEANUP_TIMEOUT_MS = 5 * 60_000; // 5min after Claude done + no FE
|
|
11
|
-
const MAX_TURN_EVENTS = 10_000; // memory safety cap
|
|
12
|
-
const BUFFERABLE_TYPES = new Set([
|
|
13
|
-
"text", "thinking", "tool_use", "tool_result",
|
|
14
|
-
"approval_request", "error", "done", "account_info", "account_retry",
|
|
15
|
-
]);
|
|
16
10
|
|
|
17
11
|
type ChatWsSocket = {
|
|
18
12
|
data: { type: string; sessionId: string; projectName?: string };
|
|
@@ -22,16 +16,21 @@ type ChatWsSocket = {
|
|
|
22
16
|
|
|
23
17
|
interface SessionEntry {
|
|
24
18
|
providerId: string;
|
|
25
|
-
|
|
19
|
+
ws: ChatWsSocket | null;
|
|
26
20
|
abort?: AbortController;
|
|
27
21
|
projectPath?: string;
|
|
28
22
|
projectName?: string;
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
pingInterval?: ReturnType<typeof setInterval>;
|
|
24
|
+
isStreaming: boolean;
|
|
31
25
|
cleanupTimer?: ReturnType<typeof setTimeout>;
|
|
32
26
|
pendingApprovalEvent?: { type: string; requestId: string; tool: string; input: unknown };
|
|
33
|
-
|
|
27
|
+
/** When true, accumulate text events until next turn boundary, then flush as one message */
|
|
28
|
+
needsCatchUp: boolean;
|
|
29
|
+
/** Accumulated text content during catch-up phase */
|
|
30
|
+
catchUpText: string;
|
|
31
|
+
/** Reference to the running stream promise — prevents GC */
|
|
34
32
|
streamPromise?: Promise<void>;
|
|
33
|
+
/** Sticky permission mode for this session */
|
|
35
34
|
permissionMode?: string;
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -41,80 +40,26 @@ const activeSessions = new Map<string, SessionEntry>();
|
|
|
41
40
|
/** Check if any frontend client is currently connected via WebSocket */
|
|
42
41
|
export function hasActiveClient(): boolean {
|
|
43
42
|
for (const entry of activeSessions.values()) {
|
|
44
|
-
if (entry.
|
|
43
|
+
if (entry.ws) return true;
|
|
45
44
|
}
|
|
46
45
|
return false;
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
/**
|
|
50
|
-
function
|
|
51
|
-
clearClientPing(entry, ws);
|
|
52
|
-
entry.clients.delete(ws);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Broadcast event to all connected clients for a session */
|
|
56
|
-
function broadcast(sessionId: string, event: unknown): void {
|
|
48
|
+
/** Send event to FE if connected, silently drop otherwise */
|
|
49
|
+
function safeSend(sessionId: string, event: unknown): void {
|
|
57
50
|
const entry = activeSessions.get(sessionId);
|
|
58
|
-
if (!entry
|
|
51
|
+
if (!entry?.ws) {
|
|
59
52
|
const evType = (event as any)?.type ?? "unknown";
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
// Log ALL dropped events (including streaming_status) for debugging first-message issues
|
|
54
|
+
if (evType !== "ping") {
|
|
55
|
+
console.warn(`[chat] session=${sessionId} safeSend: ws=null, dropping ${evType}`);
|
|
62
56
|
}
|
|
63
57
|
return;
|
|
64
58
|
}
|
|
65
|
-
const json = JSON.stringify(event);
|
|
66
|
-
for (const client of entry.clients) {
|
|
67
|
-
try { client.send(json); } catch { evictClient(entry, client); }
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Buffer event in turnEvents + broadcast to all clients */
|
|
72
|
-
function bufferAndBroadcast(sessionId: string, event: unknown): void {
|
|
73
|
-
const entry = activeSessions.get(sessionId);
|
|
74
|
-
if (!entry) return;
|
|
75
|
-
const evType = (event as any)?.type;
|
|
76
|
-
if (evType && BUFFERABLE_TYPES.has(evType)) {
|
|
77
|
-
if (entry.turnEvents.length < MAX_TURN_EVENTS) {
|
|
78
|
-
entry.turnEvents.push({ ...(event as Record<string, unknown>) });
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
broadcast(sessionId, event);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Transition session phase — guards same-phase, broadcasts phase_changed */
|
|
85
|
-
function setPhase(sessionId: string, phase: SessionPhase, elapsed?: number): void {
|
|
86
|
-
const entry = activeSessions.get(sessionId);
|
|
87
|
-
if (!entry || entry.phase === phase) return;
|
|
88
|
-
entry.phase = phase;
|
|
89
|
-
broadcast(sessionId, { type: "phase_changed", phase, ...(elapsed != null ? { elapsed } : {}) });
|
|
90
|
-
console.log(`[chat] session=${sessionId} phase → ${phase}`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Send buffered turn events to a single client (reconnect sync) */
|
|
94
|
-
function sendTurnEvents(sessionId: string, ws: ChatWsSocket): void {
|
|
95
|
-
const entry = activeSessions.get(sessionId);
|
|
96
|
-
if (!entry || entry.turnEvents.length === 0) return;
|
|
97
59
|
try {
|
|
98
|
-
ws.send(JSON.stringify(
|
|
60
|
+
entry.ws.send(JSON.stringify(event));
|
|
99
61
|
} catch (e) {
|
|
100
|
-
console.warn(`[chat] session=${sessionId}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Set up per-client application-level ping */
|
|
105
|
-
function setupClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
|
|
106
|
-
const interval = setInterval(() => {
|
|
107
|
-
try { ws.send(JSON.stringify({ type: "ping" })); } catch { /* ws may be closed */ }
|
|
108
|
-
}, PING_INTERVAL_MS);
|
|
109
|
-
entry.pingIntervals.set(ws, interval);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Clear per-client ping */
|
|
113
|
-
function clearClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
|
|
114
|
-
const interval = entry.pingIntervals.get(ws);
|
|
115
|
-
if (interval) {
|
|
116
|
-
clearInterval(interval);
|
|
117
|
-
entry.pingIntervals.delete(ws);
|
|
62
|
+
console.warn(`[chat] session=${sessionId} safeSend: send failed (${(e as Error).message})`);
|
|
118
63
|
}
|
|
119
64
|
}
|
|
120
65
|
|
|
@@ -126,8 +71,7 @@ function startCleanupTimer(sessionId: string): void {
|
|
|
126
71
|
entry.cleanupTimer = setTimeout(() => {
|
|
127
72
|
console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
|
|
128
73
|
logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
|
|
129
|
-
|
|
130
|
-
entry.pingIntervals.clear();
|
|
74
|
+
if (entry.pingInterval) clearInterval(entry.pingInterval);
|
|
131
75
|
activeSessions.delete(sessionId);
|
|
132
76
|
}, CLEANUP_TIMEOUT_MS);
|
|
133
77
|
}
|
|
@@ -143,29 +87,39 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
143
87
|
return;
|
|
144
88
|
}
|
|
145
89
|
const streamStartMs = Date.now();
|
|
146
|
-
console.log(`[chat] session=${sessionId} runStreamLoop started (
|
|
90
|
+
console.log(`[chat] session=${sessionId} runStreamLoop started (ws=${entry.ws ? "connected" : "null"})`);
|
|
147
91
|
|
|
148
92
|
const abortController = new AbortController();
|
|
149
93
|
entry.abort = abortController;
|
|
94
|
+
entry.isStreaming = true;
|
|
150
95
|
entry.pendingApprovalEvent = undefined;
|
|
151
|
-
entry.
|
|
152
|
-
|
|
96
|
+
entry.needsCatchUp = false;
|
|
97
|
+
entry.catchUpText = "";
|
|
153
98
|
|
|
99
|
+
// Heartbeat interval — declared outside try so finally can clear it
|
|
154
100
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
155
101
|
let lastContextWindowPct: number | undefined;
|
|
156
|
-
let doneEmitted = false;
|
|
157
102
|
|
|
158
103
|
try {
|
|
159
104
|
const userPreview = content.slice(0, 200);
|
|
160
105
|
logSessionEvent(sessionId, "USER", userPreview);
|
|
161
106
|
console.log(`[chat] session=${sessionId} sending message to provider=${providerId}`);
|
|
162
107
|
|
|
108
|
+
// Send "connecting" status with thinking config so FE can set appropriate warning threshold
|
|
109
|
+
const { configService } = await import("../../services/config.service.ts");
|
|
110
|
+
const ai = configService.get("ai");
|
|
111
|
+
const pCfg = ai.providers[ai.default_provider ?? "claude"] ?? {};
|
|
112
|
+
const effort = (pCfg as Record<string, unknown>).effort as string | undefined;
|
|
113
|
+
const thinkingBudget = (pCfg as Record<string, unknown>).thinking_budget_tokens as number | undefined;
|
|
114
|
+
safeSend(sessionId, { type: "streaming_status", status: "connecting", effort, thinkingBudget });
|
|
115
|
+
|
|
163
116
|
let eventCount = 0;
|
|
164
117
|
let firstEventReceived = false;
|
|
165
118
|
const startTime = Date.now();
|
|
166
119
|
|
|
167
120
|
// Heartbeat: while waiting for first response, send elapsed time every 5s
|
|
168
|
-
|
|
121
|
+
// so FE can show "Connecting... (15s)" and warn if it takes too long
|
|
122
|
+
const CONNECTION_TIMEOUT_S = 120; // 2min max wait for first SDK event
|
|
169
123
|
heartbeat = setInterval(() => {
|
|
170
124
|
if (firstEventReceived || abortController.signal.aborted) {
|
|
171
125
|
clearInterval(heartbeat);
|
|
@@ -182,15 +136,14 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
182
136
|
? "\n\nWSL detected — this is likely a network issue. Try from your WSL terminal:\n curl -s https://api.anthropic.com\nIf that fails, check WSL DNS settings (/etc/resolv.conf) or proxy configuration."
|
|
183
137
|
: "";
|
|
184
138
|
const debugCmd = projectPath ? `cd ${projectPath} && claude -p "hi"` : `claude -p "hi"`;
|
|
185
|
-
|
|
139
|
+
safeSend(sessionId, {
|
|
186
140
|
type: "error",
|
|
187
141
|
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
142
|
});
|
|
189
143
|
abortController.abort();
|
|
190
144
|
return;
|
|
191
145
|
}
|
|
192
|
-
|
|
193
|
-
broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
|
|
146
|
+
safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed });
|
|
194
147
|
}, 5_000);
|
|
195
148
|
|
|
196
149
|
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
|
|
@@ -199,32 +152,18 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
199
152
|
const ev = event as any;
|
|
200
153
|
const evType = ev.type ?? "unknown";
|
|
201
154
|
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
setPhase(sessionId, "thinking");
|
|
208
|
-
}
|
|
209
|
-
continue; // Don't buffer or broadcast system events
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// First content event — stop heartbeat, transition phase
|
|
213
|
-
const isMetadataEvent = evType === "account_info" || evType === "account_retry" || evType === "streaming_status";
|
|
155
|
+
// First content event — stop heartbeat, switch to streaming status.
|
|
156
|
+
// Skip metadata events (account_info, streaming_status) that arrive before
|
|
157
|
+
// the SDK subprocess actually produces output — keeps heartbeat + "connecting"
|
|
158
|
+
// indicator alive until real content flows.
|
|
159
|
+
const isMetadataEvent = evType === "account_info" || evType === "streaming_status";
|
|
214
160
|
if (!firstEventReceived && !isMetadataEvent) {
|
|
215
161
|
firstEventReceived = true;
|
|
216
162
|
const waitMs = Date.now() - startTime;
|
|
217
163
|
console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType}`);
|
|
218
164
|
logSessionEvent(sessionId, "PERF", `First SDK event after ${waitMs}ms (type=${evType})`);
|
|
219
165
|
if (heartbeat) clearInterval(heartbeat);
|
|
220
|
-
|
|
221
|
-
setPhase(sessionId, newPhase);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Dynamic phase transitions between thinking/streaming
|
|
225
|
-
if (firstEventReceived) {
|
|
226
|
-
if (evType === "text" && entry.phase === "thinking") setPhase(sessionId, "streaming");
|
|
227
|
-
if (evType === "thinking" && entry.phase === "streaming") setPhase(sessionId, "thinking");
|
|
166
|
+
safeSend(sessionId, { type: "streaming_status", status: "streaming" });
|
|
228
167
|
}
|
|
229
168
|
|
|
230
169
|
// Log every event
|
|
@@ -239,16 +178,15 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
239
178
|
console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
|
|
240
179
|
logSessionEvent(sessionId, "ERROR", errorDetail);
|
|
241
180
|
} else if (evType === "done") {
|
|
242
|
-
doneEmitted = true;
|
|
243
181
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
244
182
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
245
|
-
// Fire-and-forget: fetch updated session title
|
|
183
|
+
// Fire-and-forget: fetch updated session title from SDK summary
|
|
246
184
|
sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
|
|
247
185
|
const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
|
|
248
|
-
const
|
|
249
|
-
const title = dbTitle ?? found?.customTitle ?? found?.summary;
|
|
186
|
+
const title = found?.customTitle ?? found?.summary;
|
|
250
187
|
if (title) {
|
|
251
|
-
|
|
188
|
+
safeSend(sessionId, { type: "title_updated", title });
|
|
189
|
+
// Also update in-memory session title
|
|
252
190
|
const session = chatService.getSession(sessionId);
|
|
253
191
|
if (session) session.title = title;
|
|
254
192
|
}
|
|
@@ -285,8 +223,22 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
285
223
|
logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
|
|
286
224
|
}
|
|
287
225
|
|
|
288
|
-
//
|
|
289
|
-
|
|
226
|
+
// Catch-up mode: accumulate text, flush on turn boundary
|
|
227
|
+
if (entry.needsCatchUp) {
|
|
228
|
+
if (evType === "text") {
|
|
229
|
+
entry.catchUpText += ev.content ?? "";
|
|
230
|
+
} else {
|
|
231
|
+
// Non-text event = turn boundary → flush accumulated text, then send this event
|
|
232
|
+
if (entry.catchUpText) {
|
|
233
|
+
safeSend(sessionId, { type: "text", content: entry.catchUpText });
|
|
234
|
+
}
|
|
235
|
+
entry.needsCatchUp = false;
|
|
236
|
+
entry.catchUpText = "";
|
|
237
|
+
safeSend(sessionId, event);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
safeSend(sessionId, event);
|
|
241
|
+
}
|
|
290
242
|
}
|
|
291
243
|
|
|
292
244
|
logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
|
|
@@ -295,22 +247,19 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
295
247
|
const errMsg = (e as Error).message;
|
|
296
248
|
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
297
249
|
if (!abortController.signal.aborted) {
|
|
298
|
-
|
|
250
|
+
safeSend(sessionId, { type: "error", message: errMsg });
|
|
299
251
|
}
|
|
300
252
|
} finally {
|
|
301
253
|
if (heartbeat) clearInterval(heartbeat);
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
305
|
-
}
|
|
306
|
-
// 2. Clear buffer BEFORE setting phase to idle
|
|
307
|
-
entry.turnEvents = [];
|
|
308
|
-
// 3. Transition to idle
|
|
309
|
-
setPhase(sessionId, "idle");
|
|
310
|
-
// 4. Cleanup
|
|
254
|
+
// Always send done — guarantees FE resets isStreaming even if provider didn't yield done
|
|
255
|
+
safeSend(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
311
256
|
entry.abort = undefined;
|
|
257
|
+
entry.isStreaming = false;
|
|
312
258
|
entry.pendingApprovalEvent = undefined;
|
|
313
|
-
|
|
259
|
+
entry.needsCatchUp = false;
|
|
260
|
+
entry.catchUpText = "";
|
|
261
|
+
// Claude is done — if no FE connected, start cleanup timer
|
|
262
|
+
if (!entry.ws) {
|
|
314
263
|
startCleanupTimer(sessionId);
|
|
315
264
|
}
|
|
316
265
|
}
|
|
@@ -338,77 +287,77 @@ export const chatWebSocket = {
|
|
|
338
287
|
|
|
339
288
|
const existing = activeSessions.get(sessionId);
|
|
340
289
|
if (existing) {
|
|
341
|
-
// FE reconnecting to existing session — clear cleanup timer
|
|
290
|
+
// FE reconnecting to existing session — replace ws, clear cleanup timer
|
|
342
291
|
if (existing.cleanupTimer) {
|
|
343
292
|
clearTimeout(existing.cleanupTimer);
|
|
344
293
|
existing.cleanupTimer = undefined;
|
|
345
294
|
}
|
|
295
|
+
if (existing.pingInterval) clearInterval(existing.pingInterval);
|
|
296
|
+
// Use application-level pings (JSON messages) instead of protocol-level ws.ping().
|
|
297
|
+
// Protocol-level pings can be intercepted by Cloudflare tunnels, causing the server
|
|
298
|
+
// to think the connection is alive when the data path to the client is broken.
|
|
299
|
+
existing.pingInterval = setInterval(() => {
|
|
300
|
+
try {
|
|
301
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
302
|
+
} catch { /* ws may be closed */ }
|
|
303
|
+
}, PING_INTERVAL_MS);
|
|
304
|
+
existing.ws = ws;
|
|
346
305
|
if (projectPath) existing.projectPath = projectPath;
|
|
347
306
|
if (projectName) existing.projectName = projectName;
|
|
348
307
|
|
|
349
|
-
//
|
|
308
|
+
// If streaming, enter catch-up mode
|
|
309
|
+
if (existing.isStreaming) {
|
|
310
|
+
existing.needsCatchUp = true;
|
|
311
|
+
existing.catchUpText = "";
|
|
312
|
+
}
|
|
313
|
+
|
|
350
314
|
ws.send(JSON.stringify({
|
|
351
|
-
type: "
|
|
315
|
+
type: "status",
|
|
352
316
|
sessionId,
|
|
353
|
-
|
|
317
|
+
isStreaming: existing.isStreaming,
|
|
354
318
|
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
355
319
|
sessionTitle: session?.title || null,
|
|
356
320
|
}));
|
|
357
|
-
|
|
358
|
-
// If actively streaming, send buffered turn events for reconnect sync
|
|
359
|
-
if (existing.phase !== "idle") {
|
|
360
|
-
sendTurnEvents(sessionId, ws);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// NOW add to clients Set + set up ping
|
|
364
|
-
existing.clients.add(ws);
|
|
365
|
-
setupClientPing(existing, ws);
|
|
366
|
-
|
|
367
|
-
// Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
|
|
321
|
+
// Async: resolve title from SDK if in-memory title is generic
|
|
368
322
|
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
369
323
|
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
370
324
|
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
371
|
-
const
|
|
372
|
-
const title = dbTitle ?? found?.customTitle ?? found?.summary;
|
|
325
|
+
const title = found?.customTitle ?? found?.summary;
|
|
373
326
|
if (title) {
|
|
374
|
-
|
|
327
|
+
safeSend(sessionId, { type: "title_updated", title });
|
|
375
328
|
if (session) session.title = title;
|
|
376
329
|
}
|
|
377
330
|
}).catch(() => {});
|
|
378
331
|
}
|
|
379
|
-
console.log(`[chat] session=${sessionId} FE reconnected (
|
|
332
|
+
console.log(`[chat] session=${sessionId} FE reconnected (streaming=${existing.isStreaming}, catchUp=${existing.needsCatchUp})`);
|
|
380
333
|
return;
|
|
381
334
|
}
|
|
382
335
|
|
|
383
|
-
// New session entry
|
|
384
|
-
const
|
|
336
|
+
// New session entry — use application-level pings for Cloudflare tunnel compatibility
|
|
337
|
+
const pingInterval = setInterval(() => {
|
|
338
|
+
try {
|
|
339
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
340
|
+
} catch { /* ws may be closed */ }
|
|
341
|
+
}, PING_INTERVAL_MS);
|
|
342
|
+
|
|
343
|
+
activeSessions.set(sessionId, {
|
|
385
344
|
providerId,
|
|
386
|
-
|
|
345
|
+
ws,
|
|
387
346
|
projectPath,
|
|
388
347
|
projectName,
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
ws.send(JSON.stringify({
|
|
397
|
-
type: "session_state",
|
|
398
|
-
sessionId,
|
|
399
|
-
phase: "idle",
|
|
400
|
-
pendingApproval: null,
|
|
401
|
-
sessionTitle: session?.title || null,
|
|
402
|
-
}));
|
|
403
|
-
|
|
404
|
-
// Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
|
|
348
|
+
pingInterval,
|
|
349
|
+
isStreaming: false,
|
|
350
|
+
needsCatchUp: false,
|
|
351
|
+
catchUpText: "",
|
|
352
|
+
});
|
|
353
|
+
ws.send(JSON.stringify({ type: "connected", sessionId, sessionTitle: session?.title || null }));
|
|
354
|
+
// Async: resolve title from SDK if in-memory title is generic
|
|
405
355
|
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
406
356
|
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
407
357
|
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
408
|
-
const
|
|
409
|
-
const title = dbTitle ?? found?.customTitle ?? found?.summary;
|
|
358
|
+
const title = found?.customTitle ?? found?.summary;
|
|
410
359
|
if (title) {
|
|
411
|
-
|
|
360
|
+
safeSend(sessionId, { type: "title_updated", title });
|
|
412
361
|
if (session) session.title = title;
|
|
413
362
|
}
|
|
414
363
|
}).catch(() => {});
|
|
@@ -428,6 +377,12 @@ export const chatWebSocket = {
|
|
|
428
377
|
return;
|
|
429
378
|
}
|
|
430
379
|
|
|
380
|
+
// Ensure entry.ws is current — may be stale if open/close race during reconnect
|
|
381
|
+
const entry0 = activeSessions.get(sessionId);
|
|
382
|
+
if (entry0 && entry0.ws !== ws) {
|
|
383
|
+
entry0.ws = ws;
|
|
384
|
+
}
|
|
385
|
+
|
|
431
386
|
let entry = activeSessions.get(sessionId);
|
|
432
387
|
|
|
433
388
|
// Auto-create entry if missing — handles: message before open (Bun race), or session cleaned up
|
|
@@ -437,21 +392,17 @@ export const chatWebSocket = {
|
|
|
437
392
|
const pid = session?.providerId ?? providerRegistry.getDefault().id;
|
|
438
393
|
let pp: string | undefined;
|
|
439
394
|
if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
395
|
+
const pi = setInterval(() => {
|
|
396
|
+
try { ws.send(JSON.stringify({ type: "ping" })); } catch { /* ws may be closed */ }
|
|
397
|
+
}, PING_INTERVAL_MS);
|
|
398
|
+
activeSessions.set(sessionId, {
|
|
399
|
+
providerId: pid, ws, projectPath: pp, projectName: pn,
|
|
400
|
+
pingInterval: pi, isStreaming: false, needsCatchUp: false, catchUpText: "",
|
|
401
|
+
});
|
|
402
|
+
entry = activeSessions.get(sessionId)!;
|
|
447
403
|
console.log(`[chat] session=${sessionId} auto-created entry in message handler`);
|
|
448
404
|
}
|
|
449
405
|
|
|
450
|
-
// Ensure ws is in clients set
|
|
451
|
-
if (!entry.clients.has(ws)) {
|
|
452
|
-
entry.clients.add(ws);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
406
|
const providerId = entry.providerId ?? providerRegistry.getDefault().id;
|
|
456
407
|
|
|
457
408
|
// Client-initiated handshake — FE sends "ready" after onopen.
|
|
@@ -459,28 +410,24 @@ export const chatWebSocket = {
|
|
|
459
410
|
// open-handler message still get connected/status confirmation.
|
|
460
411
|
if (parsed.type === "ready") {
|
|
461
412
|
ws.send(JSON.stringify({
|
|
462
|
-
type: "
|
|
413
|
+
type: "status",
|
|
463
414
|
sessionId,
|
|
464
|
-
|
|
415
|
+
isStreaming: entry.isStreaming,
|
|
465
416
|
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
466
|
-
sessionTitle: chatService.getSession(sessionId)?.title || null,
|
|
467
417
|
}));
|
|
468
|
-
if (entry.phase !== "idle") {
|
|
469
|
-
sendTurnEvents(sessionId, ws);
|
|
470
|
-
}
|
|
471
418
|
return;
|
|
472
419
|
}
|
|
473
420
|
|
|
474
421
|
if (parsed.type === "message") {
|
|
475
|
-
if (typeof parsed.content !== "string" || !parsed.content.trim()) {
|
|
476
|
-
ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
422
|
// Store permission mode — sticky for this session
|
|
480
423
|
if (parsed.permissionMode) {
|
|
481
424
|
entry.permissionMode = parsed.permissionMode;
|
|
482
425
|
}
|
|
483
426
|
|
|
427
|
+
// Send immediate feedback BEFORE any async work — prevents "stuck thinking"
|
|
428
|
+
// when resumeSession is slow (e.g. sdkListSessions spawns subprocess on first call)
|
|
429
|
+
safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed: 0 });
|
|
430
|
+
|
|
484
431
|
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
485
432
|
const provider = providerRegistry.get(providerId);
|
|
486
433
|
if (provider && "resumeSession" in provider) {
|
|
@@ -496,33 +443,24 @@ export const chatWebSocket = {
|
|
|
496
443
|
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
497
444
|
}
|
|
498
445
|
|
|
499
|
-
//
|
|
500
|
-
if (entry.
|
|
446
|
+
// If already streaming, abort current query first and wait for cleanup
|
|
447
|
+
if (entry.isStreaming && entry.abort) {
|
|
501
448
|
console.log(`[chat] session=${sessionId} aborting current query for new message`);
|
|
502
449
|
entry.abort.abort();
|
|
450
|
+
// Wait for stream loop to finish cleanup
|
|
503
451
|
if (entry.streamPromise) {
|
|
504
452
|
await entry.streamPromise;
|
|
505
453
|
}
|
|
506
|
-
// Re-fetch entry after await — may have been mutated during cleanup
|
|
507
|
-
entry = activeSessions.get(sessionId)!;
|
|
508
|
-
if (!entry) return;
|
|
509
454
|
}
|
|
510
455
|
|
|
511
|
-
// Reset for new query
|
|
512
|
-
entry.turnEvents = [];
|
|
513
|
-
setPhase(sessionId, "initializing");
|
|
514
|
-
|
|
515
456
|
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
516
457
|
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
517
|
-
const permMode = entry.permissionMode;
|
|
518
458
|
entry.streamPromise = new Promise<void>((resolve) => {
|
|
519
459
|
setTimeout(() => {
|
|
520
|
-
runStreamLoop(sessionId, providerId, parsed.content,
|
|
460
|
+
runStreamLoop(sessionId, providerId, parsed.content, entry.permissionMode).then(resolve, resolve);
|
|
521
461
|
}, 0);
|
|
522
462
|
});
|
|
523
463
|
} else if (parsed.type === "cancel") {
|
|
524
|
-
// Signal abortController so runStreamLoop suppresses error broadcast
|
|
525
|
-
if (entry?.abort) entry.abort.abort();
|
|
526
464
|
const provider = providerRegistry.get(providerId);
|
|
527
465
|
if (provider && "abortQuery" in provider && typeof (provider as any).abortQuery === "function") {
|
|
528
466
|
(provider as any).abortQuery(sessionId);
|
|
@@ -532,11 +470,7 @@ export const chatWebSocket = {
|
|
|
532
470
|
if (provider && typeof provider.resolveApproval === "function") {
|
|
533
471
|
provider.resolveApproval(parsed.requestId, parsed.approved, (parsed as any).data);
|
|
534
472
|
}
|
|
535
|
-
if (entry)
|
|
536
|
-
entry.pendingApprovalEvent = undefined;
|
|
537
|
-
// Broadcast approval cleared to all clients
|
|
538
|
-
broadcast(sessionId, { type: "phase_changed", phase: entry.phase });
|
|
539
|
-
}
|
|
473
|
+
if (entry) entry.pendingApprovalEvent = undefined;
|
|
540
474
|
}
|
|
541
475
|
},
|
|
542
476
|
|
|
@@ -545,11 +479,16 @@ export const chatWebSocket = {
|
|
|
545
479
|
const entry = activeSessions.get(sessionId);
|
|
546
480
|
if (!entry) return;
|
|
547
481
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
482
|
+
if (entry.pingInterval) {
|
|
483
|
+
clearInterval(entry.pingInterval);
|
|
484
|
+
entry.pingInterval = undefined;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Detach FE — do NOT abort Claude
|
|
488
|
+
entry.ws = null;
|
|
489
|
+
console.log(`[chat] session=${sessionId} FE disconnected (streaming=${entry.isStreaming})`);
|
|
551
490
|
|
|
552
|
-
if (entry.
|
|
491
|
+
if (!entry.isStreaming) {
|
|
553
492
|
startCleanupTimer(sessionId);
|
|
554
493
|
}
|
|
555
494
|
},
|
|
@@ -57,14 +57,8 @@ class AccountSelectorService {
|
|
|
57
57
|
// Clear expired cooldowns
|
|
58
58
|
for (const acc of allAccounts) {
|
|
59
59
|
if (acc.status === "cooldown" && acc.cooldownUntil && acc.cooldownUntil <= now) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.retryCounts.delete(acc.id);
|
|
63
|
-
} catch {
|
|
64
|
-
// Account expired or cannot be re-enabled — disable it
|
|
65
|
-
accountService.setDisabled(acc.id);
|
|
66
|
-
this.retryCounts.delete(acc.id);
|
|
67
|
-
}
|
|
60
|
+
accountService.setEnabled(acc.id);
|
|
61
|
+
this.retryCounts.delete(acc.id);
|
|
68
62
|
}
|
|
69
63
|
}
|
|
70
64
|
|
|
@@ -124,14 +118,12 @@ class AccountSelectorService {
|
|
|
124
118
|
* Weighted sustainability score.
|
|
125
119
|
* Considers 5-hour utilization, weekly utilization, and time until weekly reset.
|
|
126
120
|
*
|
|
127
|
-
* score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio,
|
|
121
|
+
* score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio, 1.0)
|
|
128
122
|
*
|
|
129
|
-
* weeklyRemaining / resetRatio normalizes remaining capacity by time until reset
|
|
130
|
-
*
|
|
131
|
-
* -
|
|
132
|
-
* -
|
|
133
|
-
* - 44% remaining with 32h left → raw 2.32, scaled 1.00 (great — resets soon)
|
|
134
|
-
* - 20% remaining with 6h left → raw 5.6, scaled 1.00 (great — resets very soon)
|
|
123
|
+
* weeklyRemaining / resetRatio normalizes remaining capacity by time until reset:
|
|
124
|
+
* - 4% remaining with 34h left → low sustainability (0.20)
|
|
125
|
+
* - 78% remaining with 113h left → high sustainability (1.0, capped)
|
|
126
|
+
* - 20% remaining with 6h left → decent (resets soon, so it's fine)
|
|
135
127
|
*/
|
|
136
128
|
private pickLowestUsage(active: { id: string; createdAt: number }[]): string {
|
|
137
129
|
const scored = active.map((acc) => {
|
|
@@ -150,7 +142,7 @@ class AccountSelectorService {
|
|
|
150
142
|
const immediate = 1 - fiveHour;
|
|
151
143
|
const weeklyRemaining = 1 - weekly;
|
|
152
144
|
const resetRatio = weeklyResetHours / 168;
|
|
153
|
-
const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05),
|
|
145
|
+
const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05), 1.0);
|
|
154
146
|
const score = 0.35 * immediate + 0.65 * sustainability;
|
|
155
147
|
|
|
156
148
|
return { id: acc.id, score, exhausted };
|