@hienlh/ppm 0.8.58 → 0.8.60
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 +16 -0
- package/dist/web/assets/chat-tab-C5H74y2z.js +7 -0
- package/dist/web/assets/{code-editor-u6bm6bdq.js → code-editor-DMw26mUm.js} +1 -1
- package/dist/web/assets/{database-viewer-BgPBW1bJ.js → database-viewer-gnj_8u4T.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cho-kjse.js → diff-viewer-DVqfhdBN.js} +1 -1
- package/dist/web/assets/{git-graph-CktRdFwt.js → git-graph-CJy7tOAJ.js} +1 -1
- package/dist/web/assets/index-BAioKo_2.css +2 -0
- package/dist/web/assets/index-Dg6TQ3Iu.js +37 -0
- package/dist/web/assets/keybindings-store-DcxZ6WAa.js +1 -0
- package/dist/web/assets/{markdown-renderer-3_CTktzg.js → markdown-renderer--Ss7hHOm.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CH0JfEQ9.js → postgres-viewer-DMcvp0H7.js} +1 -1
- package/dist/web/assets/{settings-tab-BI0n39LJ.js → settings-tab-lC12I-a1.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DJyT7YZg.js → sqlite-viewer-BK2emL4i.js} +1 -1
- package/dist/web/assets/{tab-store-dpsCvqhH.js → tab-store-DcIBZTD4.js} +1 -1
- package/dist/web/assets/{terminal-tab-OqCohyF0.js → terminal-tab--Ag9kqvS.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/snapshot-state.md +1526 -0
- package/src/index.ts +0 -0
- package/src/providers/claude-agent-sdk.ts +16 -14
- package/src/providers/mock-provider.ts +6 -1
- package/src/server/index.ts +3 -15
- package/src/server/routes/proxy.ts +46 -53
- package/src/server/ws/chat.ts +194 -139
- package/src/services/account-selector.service.ts +8 -6
- package/src/services/account.service.ts +1 -0
- package/src/services/claude-usage.service.ts +10 -4
- package/src/services/proxy.service.ts +4 -19
- package/src/types/api.ts +9 -1
- package/src/web/components/chat/chat-tab.tsx +14 -5
- package/src/web/components/chat/message-input.tsx +39 -12
- package/src/web/components/chat/message-list.tsx +15 -12
- package/src/web/components/layout/panel-layout.tsx +17 -1
- package/src/web/components/settings/proxy-settings-section.tsx +40 -42
- package/src/web/hooks/use-chat.ts +196 -203
- package/src/web/stores/panel-store.ts +10 -10
- 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/chat-tab-cawT08fh.js +0 -7
- package/dist/web/assets/index-CpOYx0qg.js +0 -31
- package/dist/web/assets/index-WKLuYsBY.css +0 -2
- package/dist/web/assets/keybindings-store-vOnSm10D.js +0 -1
package/src/server/ws/chat.ts
CHANGED
|
@@ -3,10 +3,15 @@ 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 type { ChatWsClientMessage } from "../../types/api.ts";
|
|
6
|
+
import type { ChatWsClientMessage, SessionPhase } from "../../types/api.ts";
|
|
7
7
|
|
|
8
8
|
const PING_INTERVAL_MS = 15_000; // 15s keepalive
|
|
9
9
|
const CLEANUP_TIMEOUT_MS = 5 * 60_000; // 5min after Claude done + no FE
|
|
10
|
+
const MAX_TURN_EVENTS = 10_000; // memory safety cap
|
|
11
|
+
const BUFFERABLE_TYPES = new Set([
|
|
12
|
+
"text", "thinking", "tool_use", "tool_result",
|
|
13
|
+
"approval_request", "error", "done", "account_info",
|
|
14
|
+
]);
|
|
10
15
|
|
|
11
16
|
type ChatWsSocket = {
|
|
12
17
|
data: { type: string; sessionId: string; projectName?: string };
|
|
@@ -16,21 +21,16 @@ type ChatWsSocket = {
|
|
|
16
21
|
|
|
17
22
|
interface SessionEntry {
|
|
18
23
|
providerId: string;
|
|
19
|
-
|
|
24
|
+
clients: Set<ChatWsSocket>;
|
|
20
25
|
abort?: AbortController;
|
|
21
26
|
projectPath?: string;
|
|
22
27
|
projectName?: string;
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
pingIntervals: Map<ChatWsSocket, ReturnType<typeof setInterval>>;
|
|
29
|
+
phase: SessionPhase;
|
|
25
30
|
cleanupTimer?: ReturnType<typeof setTimeout>;
|
|
26
31
|
pendingApprovalEvent?: { type: string; requestId: string; tool: string; input: unknown };
|
|
27
|
-
|
|
28
|
-
needsCatchUp: boolean;
|
|
29
|
-
/** Accumulated text content during catch-up phase */
|
|
30
|
-
catchUpText: string;
|
|
31
|
-
/** Reference to the running stream promise — prevents GC */
|
|
32
|
+
turnEvents: unknown[];
|
|
32
33
|
streamPromise?: Promise<void>;
|
|
33
|
-
/** Sticky permission mode for this session */
|
|
34
34
|
permissionMode?: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -40,26 +40,80 @@ const activeSessions = new Map<string, SessionEntry>();
|
|
|
40
40
|
/** Check if any frontend client is currently connected via WebSocket */
|
|
41
41
|
export function hasActiveClient(): boolean {
|
|
42
42
|
for (const entry of activeSessions.values()) {
|
|
43
|
-
if (entry.
|
|
43
|
+
if (entry.clients.size > 0) return true;
|
|
44
44
|
}
|
|
45
45
|
return false;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
function
|
|
48
|
+
/** Remove a client from the session, cleaning up its ping interval */
|
|
49
|
+
function evictClient(entry: SessionEntry, ws: ChatWsSocket): void {
|
|
50
|
+
clearClientPing(entry, ws);
|
|
51
|
+
entry.clients.delete(ws);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Broadcast event to all connected clients for a session */
|
|
55
|
+
function broadcast(sessionId: string, event: unknown): void {
|
|
50
56
|
const entry = activeSessions.get(sessionId);
|
|
51
|
-
if (!entry
|
|
57
|
+
if (!entry || entry.clients.size === 0) {
|
|
52
58
|
const evType = (event as any)?.type ?? "unknown";
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
console.warn(`[chat] session=${sessionId} safeSend: ws=null, dropping ${evType}`);
|
|
59
|
+
if (evType !== "ping" && evType !== "phase_changed") {
|
|
60
|
+
console.warn(`[chat] session=${sessionId} broadcast: no clients, dropping ${evType}`);
|
|
56
61
|
}
|
|
57
62
|
return;
|
|
58
63
|
}
|
|
64
|
+
const json = JSON.stringify(event);
|
|
65
|
+
for (const client of entry.clients) {
|
|
66
|
+
try { client.send(json); } catch { evictClient(entry, client); }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Buffer event in turnEvents + broadcast to all clients */
|
|
71
|
+
function bufferAndBroadcast(sessionId: string, event: unknown): void {
|
|
72
|
+
const entry = activeSessions.get(sessionId);
|
|
73
|
+
if (!entry) return;
|
|
74
|
+
const evType = (event as any)?.type;
|
|
75
|
+
if (evType && BUFFERABLE_TYPES.has(evType)) {
|
|
76
|
+
if (entry.turnEvents.length < MAX_TURN_EVENTS) {
|
|
77
|
+
entry.turnEvents.push({ ...(event as Record<string, unknown>) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
broadcast(sessionId, event);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Transition session phase — guards same-phase, broadcasts phase_changed */
|
|
84
|
+
function setPhase(sessionId: string, phase: SessionPhase, elapsed?: number): void {
|
|
85
|
+
const entry = activeSessions.get(sessionId);
|
|
86
|
+
if (!entry || entry.phase === phase) return;
|
|
87
|
+
entry.phase = phase;
|
|
88
|
+
broadcast(sessionId, { type: "phase_changed", phase, ...(elapsed != null ? { elapsed } : {}) });
|
|
89
|
+
console.log(`[chat] session=${sessionId} phase → ${phase}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Send buffered turn events to a single client (reconnect sync) */
|
|
93
|
+
function sendTurnEvents(sessionId: string, ws: ChatWsSocket): void {
|
|
94
|
+
const entry = activeSessions.get(sessionId);
|
|
95
|
+
if (!entry || entry.turnEvents.length === 0) return;
|
|
59
96
|
try {
|
|
60
|
-
|
|
97
|
+
ws.send(JSON.stringify({ type: "turn_events", events: entry.turnEvents }));
|
|
61
98
|
} catch (e) {
|
|
62
|
-
console.warn(`[chat] session=${sessionId}
|
|
99
|
+
console.warn(`[chat] session=${sessionId} sendTurnEvents failed: ${(e as Error).message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Set up per-client application-level ping */
|
|
104
|
+
function setupClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
|
|
105
|
+
const interval = setInterval(() => {
|
|
106
|
+
try { ws.send(JSON.stringify({ type: "ping" })); } catch { /* ws may be closed */ }
|
|
107
|
+
}, PING_INTERVAL_MS);
|
|
108
|
+
entry.pingIntervals.set(ws, interval);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Clear per-client ping */
|
|
112
|
+
function clearClientPing(entry: SessionEntry, ws: ChatWsSocket): void {
|
|
113
|
+
const interval = entry.pingIntervals.get(ws);
|
|
114
|
+
if (interval) {
|
|
115
|
+
clearInterval(interval);
|
|
116
|
+
entry.pingIntervals.delete(ws);
|
|
63
117
|
}
|
|
64
118
|
}
|
|
65
119
|
|
|
@@ -71,7 +125,8 @@ function startCleanupTimer(sessionId: string): void {
|
|
|
71
125
|
entry.cleanupTimer = setTimeout(() => {
|
|
72
126
|
console.log(`[chat] session=${sessionId} cleanup: no FE reconnected within timeout`);
|
|
73
127
|
logSessionEvent(sessionId, "INFO", "Session cleaned up (no FE reconnected)");
|
|
74
|
-
|
|
128
|
+
for (const interval of entry.pingIntervals.values()) clearInterval(interval);
|
|
129
|
+
entry.pingIntervals.clear();
|
|
75
130
|
activeSessions.delete(sessionId);
|
|
76
131
|
}, CLEANUP_TIMEOUT_MS);
|
|
77
132
|
}
|
|
@@ -87,39 +142,29 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
87
142
|
return;
|
|
88
143
|
}
|
|
89
144
|
const streamStartMs = Date.now();
|
|
90
|
-
console.log(`[chat] session=${sessionId} runStreamLoop started (
|
|
145
|
+
console.log(`[chat] session=${sessionId} runStreamLoop started (clients=${entry.clients.size})`);
|
|
91
146
|
|
|
92
147
|
const abortController = new AbortController();
|
|
93
148
|
entry.abort = abortController;
|
|
94
|
-
entry.isStreaming = true;
|
|
95
149
|
entry.pendingApprovalEvent = undefined;
|
|
96
|
-
entry.
|
|
97
|
-
|
|
150
|
+
entry.turnEvents = [];
|
|
151
|
+
setPhase(sessionId, "connecting");
|
|
98
152
|
|
|
99
|
-
// Heartbeat interval — declared outside try so finally can clear it
|
|
100
153
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
101
154
|
let lastContextWindowPct: number | undefined;
|
|
155
|
+
let doneEmitted = false;
|
|
102
156
|
|
|
103
157
|
try {
|
|
104
158
|
const userPreview = content.slice(0, 200);
|
|
105
159
|
logSessionEvent(sessionId, "USER", userPreview);
|
|
106
160
|
console.log(`[chat] session=${sessionId} sending message to provider=${providerId}`);
|
|
107
161
|
|
|
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
|
-
|
|
116
162
|
let eventCount = 0;
|
|
117
163
|
let firstEventReceived = false;
|
|
118
164
|
const startTime = Date.now();
|
|
119
165
|
|
|
120
166
|
// Heartbeat: while waiting for first response, send elapsed time every 5s
|
|
121
|
-
|
|
122
|
-
const CONNECTION_TIMEOUT_S = 120; // 2min max wait for first SDK event
|
|
167
|
+
const CONNECTION_TIMEOUT_S = 120;
|
|
123
168
|
heartbeat = setInterval(() => {
|
|
124
169
|
if (firstEventReceived || abortController.signal.aborted) {
|
|
125
170
|
clearInterval(heartbeat);
|
|
@@ -136,14 +181,15 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
136
181
|
? "\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."
|
|
137
182
|
: "";
|
|
138
183
|
const debugCmd = projectPath ? `cd ${projectPath} && claude -p "hi"` : `claude -p "hi"`;
|
|
139
|
-
|
|
184
|
+
bufferAndBroadcast(sessionId, {
|
|
140
185
|
type: "error",
|
|
141
186
|
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\``,
|
|
142
187
|
});
|
|
143
188
|
abortController.abort();
|
|
144
189
|
return;
|
|
145
190
|
}
|
|
146
|
-
|
|
191
|
+
// Heartbeat uses broadcast() directly — NOT setPhase() (same-phase guard would skip elapsed updates)
|
|
192
|
+
broadcast(sessionId, { type: "phase_changed", phase: "connecting", elapsed });
|
|
147
193
|
}, 5_000);
|
|
148
194
|
|
|
149
195
|
for await (const event of chatService.sendMessage(providerId, sessionId, content, { permissionMode })) {
|
|
@@ -152,10 +198,17 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
152
198
|
const ev = event as any;
|
|
153
199
|
const evType = ev.type ?? "unknown";
|
|
154
200
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
201
|
+
// System events (hook_started, init, etc.) → transition connecting → thinking
|
|
202
|
+
// These indicate SDK has connected and is processing, but no content yet.
|
|
203
|
+
if (evType === "system") {
|
|
204
|
+
if (!firstEventReceived) {
|
|
205
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
206
|
+
setPhase(sessionId, "thinking");
|
|
207
|
+
}
|
|
208
|
+
continue; // Don't buffer or broadcast system events
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// First content event — stop heartbeat, transition phase
|
|
159
212
|
const isMetadataEvent = evType === "account_info" || evType === "streaming_status";
|
|
160
213
|
if (!firstEventReceived && !isMetadataEvent) {
|
|
161
214
|
firstEventReceived = true;
|
|
@@ -163,7 +216,14 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
163
216
|
console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType}`);
|
|
164
217
|
logSessionEvent(sessionId, "PERF", `First SDK event after ${waitMs}ms (type=${evType})`);
|
|
165
218
|
if (heartbeat) clearInterval(heartbeat);
|
|
166
|
-
|
|
219
|
+
const newPhase = evType === "thinking" ? "thinking" : "streaming";
|
|
220
|
+
setPhase(sessionId, newPhase);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Dynamic phase transitions between thinking/streaming
|
|
224
|
+
if (firstEventReceived) {
|
|
225
|
+
if (evType === "text" && entry.phase === "thinking") setPhase(sessionId, "streaming");
|
|
226
|
+
if (evType === "thinking" && entry.phase === "streaming") setPhase(sessionId, "thinking");
|
|
167
227
|
}
|
|
168
228
|
|
|
169
229
|
// Log every event
|
|
@@ -178,6 +238,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
178
238
|
console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
|
|
179
239
|
logSessionEvent(sessionId, "ERROR", errorDetail);
|
|
180
240
|
} else if (evType === "done") {
|
|
241
|
+
doneEmitted = true;
|
|
181
242
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
182
243
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
183
244
|
// Fire-and-forget: fetch updated session title from SDK summary
|
|
@@ -185,8 +246,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
185
246
|
const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
|
|
186
247
|
const title = found?.customTitle ?? found?.summary;
|
|
187
248
|
if (title) {
|
|
188
|
-
|
|
189
|
-
// Also update in-memory session title
|
|
249
|
+
broadcast(sessionId, { type: "title_updated", title });
|
|
190
250
|
const session = chatService.getSession(sessionId);
|
|
191
251
|
if (session) session.title = title;
|
|
192
252
|
}
|
|
@@ -223,22 +283,8 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
223
283
|
logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
|
|
224
284
|
}
|
|
225
285
|
|
|
226
|
-
//
|
|
227
|
-
|
|
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
|
-
}
|
|
286
|
+
// Buffer + broadcast content events
|
|
287
|
+
bufferAndBroadcast(sessionId, event);
|
|
242
288
|
}
|
|
243
289
|
|
|
244
290
|
logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
|
|
@@ -247,19 +293,22 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
247
293
|
const errMsg = (e as Error).message;
|
|
248
294
|
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
249
295
|
if (!abortController.signal.aborted) {
|
|
250
|
-
|
|
296
|
+
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
251
297
|
}
|
|
252
298
|
} finally {
|
|
253
299
|
if (heartbeat) clearInterval(heartbeat);
|
|
254
|
-
//
|
|
255
|
-
|
|
300
|
+
// 1. Buffer and broadcast done event (skip if SDK already yielded one)
|
|
301
|
+
if (!doneEmitted) {
|
|
302
|
+
bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
303
|
+
}
|
|
304
|
+
// 2. Clear buffer BEFORE setting phase to idle
|
|
305
|
+
entry.turnEvents = [];
|
|
306
|
+
// 3. Transition to idle
|
|
307
|
+
setPhase(sessionId, "idle");
|
|
308
|
+
// 4. Cleanup
|
|
256
309
|
entry.abort = undefined;
|
|
257
|
-
entry.isStreaming = false;
|
|
258
310
|
entry.pendingApprovalEvent = undefined;
|
|
259
|
-
entry.
|
|
260
|
-
entry.catchUpText = "";
|
|
261
|
-
// Claude is done — if no FE connected, start cleanup timer
|
|
262
|
-
if (!entry.ws) {
|
|
311
|
+
if (entry.clients.size === 0) {
|
|
263
312
|
startCleanupTimer(sessionId);
|
|
264
313
|
}
|
|
265
314
|
}
|
|
@@ -287,77 +336,75 @@ export const chatWebSocket = {
|
|
|
287
336
|
|
|
288
337
|
const existing = activeSessions.get(sessionId);
|
|
289
338
|
if (existing) {
|
|
290
|
-
// FE reconnecting to existing session —
|
|
339
|
+
// FE reconnecting to existing session — clear cleanup timer
|
|
291
340
|
if (existing.cleanupTimer) {
|
|
292
341
|
clearTimeout(existing.cleanupTimer);
|
|
293
342
|
existing.cleanupTimer = undefined;
|
|
294
343
|
}
|
|
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;
|
|
305
344
|
if (projectPath) existing.projectPath = projectPath;
|
|
306
345
|
if (projectName) existing.projectName = projectName;
|
|
307
346
|
|
|
308
|
-
//
|
|
309
|
-
if (existing.isStreaming) {
|
|
310
|
-
existing.needsCatchUp = true;
|
|
311
|
-
existing.catchUpText = "";
|
|
312
|
-
}
|
|
313
|
-
|
|
347
|
+
// Send state + turnEvents BEFORE joining clients Set (ordering matters)
|
|
314
348
|
ws.send(JSON.stringify({
|
|
315
|
-
type: "
|
|
349
|
+
type: "session_state",
|
|
316
350
|
sessionId,
|
|
317
|
-
|
|
351
|
+
phase: existing.phase,
|
|
318
352
|
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
319
353
|
sessionTitle: session?.title || null,
|
|
320
354
|
}));
|
|
355
|
+
|
|
356
|
+
// If actively streaming, send buffered turn events for reconnect sync
|
|
357
|
+
if (existing.phase !== "idle") {
|
|
358
|
+
sendTurnEvents(sessionId, ws);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// NOW add to clients Set + set up ping
|
|
362
|
+
existing.clients.add(ws);
|
|
363
|
+
setupClientPing(existing, ws);
|
|
364
|
+
|
|
321
365
|
// Async: resolve title from SDK if in-memory title is generic
|
|
322
366
|
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
323
367
|
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
324
368
|
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
325
369
|
const title = found?.customTitle ?? found?.summary;
|
|
326
370
|
if (title) {
|
|
327
|
-
|
|
371
|
+
broadcast(sessionId, { type: "title_updated", title });
|
|
328
372
|
if (session) session.title = title;
|
|
329
373
|
}
|
|
330
374
|
}).catch(() => {});
|
|
331
375
|
}
|
|
332
|
-
console.log(`[chat] session=${sessionId} FE reconnected (
|
|
376
|
+
console.log(`[chat] session=${sessionId} FE reconnected (phase=${existing.phase}, clients=${existing.clients.size})`);
|
|
333
377
|
return;
|
|
334
378
|
}
|
|
335
379
|
|
|
336
|
-
// New session entry
|
|
337
|
-
const
|
|
338
|
-
try {
|
|
339
|
-
ws.send(JSON.stringify({ type: "ping" }));
|
|
340
|
-
} catch { /* ws may be closed */ }
|
|
341
|
-
}, PING_INTERVAL_MS);
|
|
342
|
-
|
|
343
|
-
activeSessions.set(sessionId, {
|
|
380
|
+
// New session entry
|
|
381
|
+
const newEntry: SessionEntry = {
|
|
344
382
|
providerId,
|
|
345
|
-
ws,
|
|
383
|
+
clients: new Set([ws]),
|
|
346
384
|
projectPath,
|
|
347
385
|
projectName,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
386
|
+
pingIntervals: new Map(),
|
|
387
|
+
phase: "idle",
|
|
388
|
+
turnEvents: [],
|
|
389
|
+
};
|
|
390
|
+
activeSessions.set(sessionId, newEntry);
|
|
391
|
+
setupClientPing(newEntry, ws);
|
|
392
|
+
|
|
393
|
+
ws.send(JSON.stringify({
|
|
394
|
+
type: "session_state",
|
|
395
|
+
sessionId,
|
|
396
|
+
phase: "idle",
|
|
397
|
+
pendingApproval: null,
|
|
398
|
+
sessionTitle: session?.title || null,
|
|
399
|
+
}));
|
|
400
|
+
|
|
354
401
|
// Async: resolve title from SDK if in-memory title is generic
|
|
355
402
|
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
356
403
|
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
357
404
|
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
358
405
|
const title = found?.customTitle ?? found?.summary;
|
|
359
406
|
if (title) {
|
|
360
|
-
|
|
407
|
+
broadcast(sessionId, { type: "title_updated", title });
|
|
361
408
|
if (session) session.title = title;
|
|
362
409
|
}
|
|
363
410
|
}).catch(() => {});
|
|
@@ -377,12 +424,6 @@ export const chatWebSocket = {
|
|
|
377
424
|
return;
|
|
378
425
|
}
|
|
379
426
|
|
|
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
|
-
|
|
386
427
|
let entry = activeSessions.get(sessionId);
|
|
387
428
|
|
|
388
429
|
// Auto-create entry if missing — handles: message before open (Bun race), or session cleaned up
|
|
@@ -392,17 +433,21 @@ export const chatWebSocket = {
|
|
|
392
433
|
const pid = session?.providerId ?? providerRegistry.getDefault().id;
|
|
393
434
|
let pp: string | undefined;
|
|
394
435
|
if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
entry = activeSessions.get(sessionId)!;
|
|
436
|
+
const newEntry: SessionEntry = {
|
|
437
|
+
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
438
|
+
pingIntervals: new Map(), phase: "idle", turnEvents: [],
|
|
439
|
+
};
|
|
440
|
+
activeSessions.set(sessionId, newEntry);
|
|
441
|
+
setupClientPing(newEntry, ws);
|
|
442
|
+
entry = newEntry;
|
|
403
443
|
console.log(`[chat] session=${sessionId} auto-created entry in message handler`);
|
|
404
444
|
}
|
|
405
445
|
|
|
446
|
+
// Ensure ws is in clients set
|
|
447
|
+
if (!entry.clients.has(ws)) {
|
|
448
|
+
entry.clients.add(ws);
|
|
449
|
+
}
|
|
450
|
+
|
|
406
451
|
const providerId = entry.providerId ?? providerRegistry.getDefault().id;
|
|
407
452
|
|
|
408
453
|
// Client-initiated handshake — FE sends "ready" after onopen.
|
|
@@ -410,24 +455,28 @@ export const chatWebSocket = {
|
|
|
410
455
|
// open-handler message still get connected/status confirmation.
|
|
411
456
|
if (parsed.type === "ready") {
|
|
412
457
|
ws.send(JSON.stringify({
|
|
413
|
-
type: "
|
|
458
|
+
type: "session_state",
|
|
414
459
|
sessionId,
|
|
415
|
-
|
|
460
|
+
phase: entry.phase,
|
|
416
461
|
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
462
|
+
sessionTitle: chatService.getSession(sessionId)?.title || null,
|
|
417
463
|
}));
|
|
464
|
+
if (entry.phase !== "idle") {
|
|
465
|
+
sendTurnEvents(sessionId, ws);
|
|
466
|
+
}
|
|
418
467
|
return;
|
|
419
468
|
}
|
|
420
469
|
|
|
421
470
|
if (parsed.type === "message") {
|
|
471
|
+
if (typeof parsed.content !== "string" || !parsed.content.trim()) {
|
|
472
|
+
ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
422
475
|
// Store permission mode — sticky for this session
|
|
423
476
|
if (parsed.permissionMode) {
|
|
424
477
|
entry.permissionMode = parsed.permissionMode;
|
|
425
478
|
}
|
|
426
479
|
|
|
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
|
-
|
|
431
480
|
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
432
481
|
const provider = providerRegistry.get(providerId);
|
|
433
482
|
if (provider && "resumeSession" in provider) {
|
|
@@ -443,21 +492,28 @@ export const chatWebSocket = {
|
|
|
443
492
|
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
444
493
|
}
|
|
445
494
|
|
|
446
|
-
//
|
|
447
|
-
if (entry.
|
|
495
|
+
// Abort-and-replace: if already streaming, abort current query and wait for cleanup
|
|
496
|
+
if (entry.phase !== "idle" && entry.abort) {
|
|
448
497
|
console.log(`[chat] session=${sessionId} aborting current query for new message`);
|
|
449
498
|
entry.abort.abort();
|
|
450
|
-
// Wait for stream loop to finish cleanup
|
|
451
499
|
if (entry.streamPromise) {
|
|
452
500
|
await entry.streamPromise;
|
|
453
501
|
}
|
|
502
|
+
// Re-fetch entry after await — may have been mutated during cleanup
|
|
503
|
+
entry = activeSessions.get(sessionId)!;
|
|
504
|
+
if (!entry) return;
|
|
454
505
|
}
|
|
455
506
|
|
|
507
|
+
// Reset for new query
|
|
508
|
+
entry.turnEvents = [];
|
|
509
|
+
setPhase(sessionId, "initializing");
|
|
510
|
+
|
|
456
511
|
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
457
512
|
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
513
|
+
const permMode = entry.permissionMode;
|
|
458
514
|
entry.streamPromise = new Promise<void>((resolve) => {
|
|
459
515
|
setTimeout(() => {
|
|
460
|
-
runStreamLoop(sessionId, providerId, parsed.content,
|
|
516
|
+
runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
|
|
461
517
|
}, 0);
|
|
462
518
|
});
|
|
463
519
|
} else if (parsed.type === "cancel") {
|
|
@@ -470,7 +526,11 @@ export const chatWebSocket = {
|
|
|
470
526
|
if (provider && typeof provider.resolveApproval === "function") {
|
|
471
527
|
provider.resolveApproval(parsed.requestId, parsed.approved, (parsed as any).data);
|
|
472
528
|
}
|
|
473
|
-
if (entry)
|
|
529
|
+
if (entry) {
|
|
530
|
+
entry.pendingApprovalEvent = undefined;
|
|
531
|
+
// Broadcast approval cleared to all clients
|
|
532
|
+
broadcast(sessionId, { type: "phase_changed", phase: entry.phase });
|
|
533
|
+
}
|
|
474
534
|
}
|
|
475
535
|
},
|
|
476
536
|
|
|
@@ -479,16 +539,11 @@ export const chatWebSocket = {
|
|
|
479
539
|
const entry = activeSessions.get(sessionId);
|
|
480
540
|
if (!entry) return;
|
|
481
541
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Detach FE — do NOT abort Claude
|
|
488
|
-
entry.ws = null;
|
|
489
|
-
console.log(`[chat] session=${sessionId} FE disconnected (streaming=${entry.isStreaming})`);
|
|
542
|
+
// Remove from clients Set + clear per-client ping
|
|
543
|
+
evictClient(entry, ws);
|
|
544
|
+
console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
|
|
490
545
|
|
|
491
|
-
if (
|
|
546
|
+
if (entry.clients.size === 0 && entry.phase === "idle") {
|
|
492
547
|
startCleanupTimer(sessionId);
|
|
493
548
|
}
|
|
494
549
|
},
|
|
@@ -118,12 +118,14 @@ class AccountSelectorService {
|
|
|
118
118
|
* Weighted sustainability score.
|
|
119
119
|
* Considers 5-hour utilization, weekly utilization, and time until weekly reset.
|
|
120
120
|
*
|
|
121
|
-
* score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio,
|
|
121
|
+
* score = 0.35 × (1 - 5hr) + 0.65 × min(weeklyRemaining / resetRatio, 2.0) / 2.0
|
|
122
122
|
*
|
|
123
|
-
* weeklyRemaining / resetRatio normalizes remaining capacity by time until reset
|
|
124
|
-
*
|
|
125
|
-
* -
|
|
126
|
-
* -
|
|
123
|
+
* weeklyRemaining / resetRatio normalizes remaining capacity by time until reset.
|
|
124
|
+
* Capped at 2.0 (not 1.0) so accounts with imminent reset score higher:
|
|
125
|
+
* - 4% remaining with 34h left → raw 0.20, scaled 0.10 (low)
|
|
126
|
+
* - 78% remaining with 113h left → raw 1.16, scaled 0.58 (good)
|
|
127
|
+
* - 44% remaining with 32h left → raw 2.32, scaled 1.00 (great — resets soon)
|
|
128
|
+
* - 20% remaining with 6h left → raw 5.6, scaled 1.00 (great — resets very soon)
|
|
127
129
|
*/
|
|
128
130
|
private pickLowestUsage(active: { id: string; createdAt: number }[]): string {
|
|
129
131
|
const scored = active.map((acc) => {
|
|
@@ -142,7 +144,7 @@ class AccountSelectorService {
|
|
|
142
144
|
const immediate = 1 - fiveHour;
|
|
143
145
|
const weeklyRemaining = 1 - weekly;
|
|
144
146
|
const resetRatio = weeklyResetHours / 168;
|
|
145
|
-
const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05),
|
|
147
|
+
const sustainability = Math.min(weeklyRemaining / Math.max(resetRatio, 0.05), 2.0) / 2.0;
|
|
146
148
|
const score = 0.35 * immediate + 0.65 * sustainability;
|
|
147
149
|
|
|
148
150
|
return { id: acc.id, score, exhausted };
|
|
@@ -298,15 +298,21 @@ export function getCachedUsage(): ClaudeUsage & { activeAccountId?: string; acti
|
|
|
298
298
|
|
|
299
299
|
export function startUsagePolling(): void {
|
|
300
300
|
if (pollTimer) return;
|
|
301
|
-
|
|
302
|
-
// and ensure polling continues even if a single iteration errors
|
|
301
|
+
const POLL_TIMEOUT = 60_000; // max 60s per poll iteration
|
|
303
302
|
const scheduleNext = () => {
|
|
304
303
|
pollTimer = setTimeout(async () => {
|
|
305
|
-
|
|
304
|
+
try {
|
|
305
|
+
await Promise.race([
|
|
306
|
+
pollOnce(),
|
|
307
|
+
new Promise<void>(r => setTimeout(r, POLL_TIMEOUT)),
|
|
308
|
+
]);
|
|
309
|
+
} catch {
|
|
310
|
+
// ignore — scheduleNext runs regardless
|
|
311
|
+
}
|
|
306
312
|
scheduleNext();
|
|
307
313
|
}, POLL_INTERVAL);
|
|
308
314
|
};
|
|
309
|
-
pollOnce().then(scheduleNext);
|
|
315
|
+
pollOnce().then(scheduleNext, scheduleNext);
|
|
310
316
|
}
|
|
311
317
|
|
|
312
318
|
export function stopUsagePolling(): void {
|