@hienlh/ppm 0.8.58 → 0.9.0-beta.0
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 +34 -0
- package/dist/web/assets/chat-tab-BDyjEN8p.js +7 -0
- package/dist/web/assets/{code-editor-u6bm6bdq.js → code-editor-BmFI-Khj.js} +1 -1
- package/dist/web/assets/{database-viewer-BgPBW1bJ.js → database-viewer-Cb7tqJqX.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cho-kjse.js → diff-viewer-D_f9S4Ya.js} +1 -1
- package/dist/web/assets/{git-graph-CktRdFwt.js → git-graph-Co3a8y4i.js} +1 -1
- package/dist/web/assets/index-BAioKo_2.css +2 -0
- package/dist/web/assets/{index-CpOYx0qg.js → index-CqMDTnLp.js} +13 -7
- package/dist/web/assets/keybindings-store-CulLCWPX.js +1 -0
- package/dist/web/assets/{markdown-renderer-3_CTktzg.js → markdown-renderer-xipSjvIr.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CH0JfEQ9.js → postgres-viewer-p5tz14oN.js} +1 -1
- package/dist/web/assets/{settings-tab-BI0n39LJ.js → settings-tab-CCz5ftre.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-DJyT7YZg.js → sqlite-viewer-vF9L6tfk.js} +1 -1
- package/dist/web/assets/{terminal-tab-OqCohyF0.js → terminal-tab-XbV1JFTB.js} +1 -1
- package/dist/web/index.html +2 -2
- 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/server/index.ts +3 -15
- package/src/server/routes/proxy.ts +46 -53
- package/src/server/ws/chat.ts +184 -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-list.tsx +15 -12
- package/src/web/components/settings/proxy-settings-section.tsx +40 -42
- package/src/web/hooks/use-chat.ts +196 -203
- 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-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,7 @@ 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
|
-
// First content event — stop heartbeat,
|
|
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.
|
|
201
|
+
// First content event — stop heartbeat, transition phase
|
|
159
202
|
const isMetadataEvent = evType === "account_info" || evType === "streaming_status";
|
|
160
203
|
if (!firstEventReceived && !isMetadataEvent) {
|
|
161
204
|
firstEventReceived = true;
|
|
@@ -163,7 +206,14 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
163
206
|
console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType}`);
|
|
164
207
|
logSessionEvent(sessionId, "PERF", `First SDK event after ${waitMs}ms (type=${evType})`);
|
|
165
208
|
if (heartbeat) clearInterval(heartbeat);
|
|
166
|
-
|
|
209
|
+
const newPhase = evType === "thinking" ? "thinking" : "streaming";
|
|
210
|
+
setPhase(sessionId, newPhase);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Dynamic phase transitions between thinking/streaming
|
|
214
|
+
if (firstEventReceived) {
|
|
215
|
+
if (evType === "text" && entry.phase === "thinking") setPhase(sessionId, "streaming");
|
|
216
|
+
if (evType === "thinking" && entry.phase === "streaming") setPhase(sessionId, "thinking");
|
|
167
217
|
}
|
|
168
218
|
|
|
169
219
|
// Log every event
|
|
@@ -178,6 +228,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
178
228
|
console.error(`[chat] session=${sessionId} error: ${errorDetail}`);
|
|
179
229
|
logSessionEvent(sessionId, "ERROR", errorDetail);
|
|
180
230
|
} else if (evType === "done") {
|
|
231
|
+
doneEmitted = true;
|
|
181
232
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
182
233
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
183
234
|
// Fire-and-forget: fetch updated session title from SDK summary
|
|
@@ -185,8 +236,7 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
185
236
|
const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
|
|
186
237
|
const title = found?.customTitle ?? found?.summary;
|
|
187
238
|
if (title) {
|
|
188
|
-
|
|
189
|
-
// Also update in-memory session title
|
|
239
|
+
broadcast(sessionId, { type: "title_updated", title });
|
|
190
240
|
const session = chatService.getSession(sessionId);
|
|
191
241
|
if (session) session.title = title;
|
|
192
242
|
}
|
|
@@ -223,22 +273,8 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
223
273
|
logSessionEvent(sessionId, evType.toUpperCase(), JSON.stringify(ev).slice(0, 200));
|
|
224
274
|
}
|
|
225
275
|
|
|
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
|
-
}
|
|
276
|
+
// Buffer + broadcast content events
|
|
277
|
+
bufferAndBroadcast(sessionId, event);
|
|
242
278
|
}
|
|
243
279
|
|
|
244
280
|
logSessionEvent(sessionId, "INFO", `Stream completed (${eventCount} events)`);
|
|
@@ -247,19 +283,22 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
247
283
|
const errMsg = (e as Error).message;
|
|
248
284
|
logSessionEvent(sessionId, "ERROR", `Exception: ${errMsg}`);
|
|
249
285
|
if (!abortController.signal.aborted) {
|
|
250
|
-
|
|
286
|
+
bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
|
|
251
287
|
}
|
|
252
288
|
} finally {
|
|
253
289
|
if (heartbeat) clearInterval(heartbeat);
|
|
254
|
-
//
|
|
255
|
-
|
|
290
|
+
// 1. Buffer and broadcast done event (skip if SDK already yielded one)
|
|
291
|
+
if (!doneEmitted) {
|
|
292
|
+
bufferAndBroadcast(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
|
|
293
|
+
}
|
|
294
|
+
// 2. Clear buffer BEFORE setting phase to idle
|
|
295
|
+
entry.turnEvents = [];
|
|
296
|
+
// 3. Transition to idle
|
|
297
|
+
setPhase(sessionId, "idle");
|
|
298
|
+
// 4. Cleanup
|
|
256
299
|
entry.abort = undefined;
|
|
257
|
-
entry.isStreaming = false;
|
|
258
300
|
entry.pendingApprovalEvent = undefined;
|
|
259
|
-
entry.
|
|
260
|
-
entry.catchUpText = "";
|
|
261
|
-
// Claude is done — if no FE connected, start cleanup timer
|
|
262
|
-
if (!entry.ws) {
|
|
301
|
+
if (entry.clients.size === 0) {
|
|
263
302
|
startCleanupTimer(sessionId);
|
|
264
303
|
}
|
|
265
304
|
}
|
|
@@ -287,77 +326,75 @@ export const chatWebSocket = {
|
|
|
287
326
|
|
|
288
327
|
const existing = activeSessions.get(sessionId);
|
|
289
328
|
if (existing) {
|
|
290
|
-
// FE reconnecting to existing session —
|
|
329
|
+
// FE reconnecting to existing session — clear cleanup timer
|
|
291
330
|
if (existing.cleanupTimer) {
|
|
292
331
|
clearTimeout(existing.cleanupTimer);
|
|
293
332
|
existing.cleanupTimer = undefined;
|
|
294
333
|
}
|
|
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
334
|
if (projectPath) existing.projectPath = projectPath;
|
|
306
335
|
if (projectName) existing.projectName = projectName;
|
|
307
336
|
|
|
308
|
-
//
|
|
309
|
-
if (existing.isStreaming) {
|
|
310
|
-
existing.needsCatchUp = true;
|
|
311
|
-
existing.catchUpText = "";
|
|
312
|
-
}
|
|
313
|
-
|
|
337
|
+
// Send state + turnEvents BEFORE joining clients Set (ordering matters)
|
|
314
338
|
ws.send(JSON.stringify({
|
|
315
|
-
type: "
|
|
339
|
+
type: "session_state",
|
|
316
340
|
sessionId,
|
|
317
|
-
|
|
341
|
+
phase: existing.phase,
|
|
318
342
|
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
319
343
|
sessionTitle: session?.title || null,
|
|
320
344
|
}));
|
|
345
|
+
|
|
346
|
+
// If actively streaming, send buffered turn events for reconnect sync
|
|
347
|
+
if (existing.phase !== "idle") {
|
|
348
|
+
sendTurnEvents(sessionId, ws);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// NOW add to clients Set + set up ping
|
|
352
|
+
existing.clients.add(ws);
|
|
353
|
+
setupClientPing(existing, ws);
|
|
354
|
+
|
|
321
355
|
// Async: resolve title from SDK if in-memory title is generic
|
|
322
356
|
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
323
357
|
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
324
358
|
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
325
359
|
const title = found?.customTitle ?? found?.summary;
|
|
326
360
|
if (title) {
|
|
327
|
-
|
|
361
|
+
broadcast(sessionId, { type: "title_updated", title });
|
|
328
362
|
if (session) session.title = title;
|
|
329
363
|
}
|
|
330
364
|
}).catch(() => {});
|
|
331
365
|
}
|
|
332
|
-
console.log(`[chat] session=${sessionId} FE reconnected (
|
|
366
|
+
console.log(`[chat] session=${sessionId} FE reconnected (phase=${existing.phase}, clients=${existing.clients.size})`);
|
|
333
367
|
return;
|
|
334
368
|
}
|
|
335
369
|
|
|
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, {
|
|
370
|
+
// New session entry
|
|
371
|
+
const newEntry: SessionEntry = {
|
|
344
372
|
providerId,
|
|
345
|
-
ws,
|
|
373
|
+
clients: new Set([ws]),
|
|
346
374
|
projectPath,
|
|
347
375
|
projectName,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
376
|
+
pingIntervals: new Map(),
|
|
377
|
+
phase: "idle",
|
|
378
|
+
turnEvents: [],
|
|
379
|
+
};
|
|
380
|
+
activeSessions.set(sessionId, newEntry);
|
|
381
|
+
setupClientPing(newEntry, ws);
|
|
382
|
+
|
|
383
|
+
ws.send(JSON.stringify({
|
|
384
|
+
type: "session_state",
|
|
385
|
+
sessionId,
|
|
386
|
+
phase: "idle",
|
|
387
|
+
pendingApproval: null,
|
|
388
|
+
sessionTitle: session?.title || null,
|
|
389
|
+
}));
|
|
390
|
+
|
|
354
391
|
// Async: resolve title from SDK if in-memory title is generic
|
|
355
392
|
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
356
393
|
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
357
394
|
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
358
395
|
const title = found?.customTitle ?? found?.summary;
|
|
359
396
|
if (title) {
|
|
360
|
-
|
|
397
|
+
broadcast(sessionId, { type: "title_updated", title });
|
|
361
398
|
if (session) session.title = title;
|
|
362
399
|
}
|
|
363
400
|
}).catch(() => {});
|
|
@@ -377,12 +414,6 @@ export const chatWebSocket = {
|
|
|
377
414
|
return;
|
|
378
415
|
}
|
|
379
416
|
|
|
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
417
|
let entry = activeSessions.get(sessionId);
|
|
387
418
|
|
|
388
419
|
// Auto-create entry if missing — handles: message before open (Bun race), or session cleaned up
|
|
@@ -392,17 +423,21 @@ export const chatWebSocket = {
|
|
|
392
423
|
const pid = session?.providerId ?? providerRegistry.getDefault().id;
|
|
393
424
|
let pp: string | undefined;
|
|
394
425
|
if (pn) { try { pp = resolveProjectPath(pn); } catch { /* ignore */ } }
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
entry = activeSessions.get(sessionId)!;
|
|
426
|
+
const newEntry: SessionEntry = {
|
|
427
|
+
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
428
|
+
pingIntervals: new Map(), phase: "idle", turnEvents: [],
|
|
429
|
+
};
|
|
430
|
+
activeSessions.set(sessionId, newEntry);
|
|
431
|
+
setupClientPing(newEntry, ws);
|
|
432
|
+
entry = newEntry;
|
|
403
433
|
console.log(`[chat] session=${sessionId} auto-created entry in message handler`);
|
|
404
434
|
}
|
|
405
435
|
|
|
436
|
+
// Ensure ws is in clients set
|
|
437
|
+
if (!entry.clients.has(ws)) {
|
|
438
|
+
entry.clients.add(ws);
|
|
439
|
+
}
|
|
440
|
+
|
|
406
441
|
const providerId = entry.providerId ?? providerRegistry.getDefault().id;
|
|
407
442
|
|
|
408
443
|
// Client-initiated handshake — FE sends "ready" after onopen.
|
|
@@ -410,24 +445,28 @@ export const chatWebSocket = {
|
|
|
410
445
|
// open-handler message still get connected/status confirmation.
|
|
411
446
|
if (parsed.type === "ready") {
|
|
412
447
|
ws.send(JSON.stringify({
|
|
413
|
-
type: "
|
|
448
|
+
type: "session_state",
|
|
414
449
|
sessionId,
|
|
415
|
-
|
|
450
|
+
phase: entry.phase,
|
|
416
451
|
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
452
|
+
sessionTitle: chatService.getSession(sessionId)?.title || null,
|
|
417
453
|
}));
|
|
454
|
+
if (entry.phase !== "idle") {
|
|
455
|
+
sendTurnEvents(sessionId, ws);
|
|
456
|
+
}
|
|
418
457
|
return;
|
|
419
458
|
}
|
|
420
459
|
|
|
421
460
|
if (parsed.type === "message") {
|
|
461
|
+
if (typeof parsed.content !== "string" || !parsed.content.trim()) {
|
|
462
|
+
ws.send(JSON.stringify({ type: "error", message: "Message content is required" }));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
422
465
|
// Store permission mode — sticky for this session
|
|
423
466
|
if (parsed.permissionMode) {
|
|
424
467
|
entry.permissionMode = parsed.permissionMode;
|
|
425
468
|
}
|
|
426
469
|
|
|
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
470
|
// Resume session in provider (can be slow on first call — sdkListSessions)
|
|
432
471
|
const provider = providerRegistry.get(providerId);
|
|
433
472
|
if (provider && "resumeSession" in provider) {
|
|
@@ -443,21 +482,28 @@ export const chatWebSocket = {
|
|
|
443
482
|
(provider as any).ensureProjectPath(sessionId, entry.projectPath);
|
|
444
483
|
}
|
|
445
484
|
|
|
446
|
-
//
|
|
447
|
-
if (entry.
|
|
485
|
+
// Abort-and-replace: if already streaming, abort current query and wait for cleanup
|
|
486
|
+
if (entry.phase !== "idle" && entry.abort) {
|
|
448
487
|
console.log(`[chat] session=${sessionId} aborting current query for new message`);
|
|
449
488
|
entry.abort.abort();
|
|
450
|
-
// Wait for stream loop to finish cleanup
|
|
451
489
|
if (entry.streamPromise) {
|
|
452
490
|
await entry.streamPromise;
|
|
453
491
|
}
|
|
492
|
+
// Re-fetch entry after await — may have been mutated during cleanup
|
|
493
|
+
entry = activeSessions.get(sessionId)!;
|
|
494
|
+
if (!entry) return;
|
|
454
495
|
}
|
|
455
496
|
|
|
497
|
+
// Reset for new query
|
|
498
|
+
entry.turnEvents = [];
|
|
499
|
+
setPhase(sessionId, "initializing");
|
|
500
|
+
|
|
456
501
|
// Store promise reference on entry to prevent GC from collecting the async operation.
|
|
457
502
|
// Use setTimeout(0) to detach from WS handler's async scope.
|
|
503
|
+
const permMode = entry.permissionMode;
|
|
458
504
|
entry.streamPromise = new Promise<void>((resolve) => {
|
|
459
505
|
setTimeout(() => {
|
|
460
|
-
runStreamLoop(sessionId, providerId, parsed.content,
|
|
506
|
+
runStreamLoop(sessionId, providerId, parsed.content, permMode).then(resolve, resolve);
|
|
461
507
|
}, 0);
|
|
462
508
|
});
|
|
463
509
|
} else if (parsed.type === "cancel") {
|
|
@@ -470,7 +516,11 @@ export const chatWebSocket = {
|
|
|
470
516
|
if (provider && typeof provider.resolveApproval === "function") {
|
|
471
517
|
provider.resolveApproval(parsed.requestId, parsed.approved, (parsed as any).data);
|
|
472
518
|
}
|
|
473
|
-
if (entry)
|
|
519
|
+
if (entry) {
|
|
520
|
+
entry.pendingApprovalEvent = undefined;
|
|
521
|
+
// Broadcast approval cleared to all clients
|
|
522
|
+
broadcast(sessionId, { type: "phase_changed", phase: entry.phase });
|
|
523
|
+
}
|
|
474
524
|
}
|
|
475
525
|
},
|
|
476
526
|
|
|
@@ -479,16 +529,11 @@ export const chatWebSocket = {
|
|
|
479
529
|
const entry = activeSessions.get(sessionId);
|
|
480
530
|
if (!entry) return;
|
|
481
531
|
|
|
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})`);
|
|
532
|
+
// Remove from clients Set + clear per-client ping
|
|
533
|
+
evictClient(entry, ws);
|
|
534
|
+
console.log(`[chat] session=${sessionId} FE disconnected (phase=${entry.phase}, clients=${entry.clients.size})`);
|
|
490
535
|
|
|
491
|
-
if (
|
|
536
|
+
if (entry.clients.size === 0 && entry.phase === "idle") {
|
|
492
537
|
startCleanupTimer(sessionId);
|
|
493
538
|
}
|
|
494
539
|
},
|
|
@@ -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 {
|