@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/web/assets/chat-tab-C5H74y2z.js +7 -0
  3. package/dist/web/assets/{code-editor-u6bm6bdq.js → code-editor-DMw26mUm.js} +1 -1
  4. package/dist/web/assets/{database-viewer-BgPBW1bJ.js → database-viewer-gnj_8u4T.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-Cho-kjse.js → diff-viewer-DVqfhdBN.js} +1 -1
  6. package/dist/web/assets/{git-graph-CktRdFwt.js → git-graph-CJy7tOAJ.js} +1 -1
  7. package/dist/web/assets/index-BAioKo_2.css +2 -0
  8. package/dist/web/assets/index-Dg6TQ3Iu.js +37 -0
  9. package/dist/web/assets/keybindings-store-DcxZ6WAa.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-3_CTktzg.js → markdown-renderer--Ss7hHOm.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-CH0JfEQ9.js → postgres-viewer-DMcvp0H7.js} +1 -1
  12. package/dist/web/assets/{settings-tab-BI0n39LJ.js → settings-tab-lC12I-a1.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-DJyT7YZg.js → sqlite-viewer-BK2emL4i.js} +1 -1
  14. package/dist/web/assets/{tab-store-dpsCvqhH.js → tab-store-DcIBZTD4.js} +1 -1
  15. package/dist/web/assets/{terminal-tab-OqCohyF0.js → terminal-tab--Ag9kqvS.js} +1 -1
  16. package/dist/web/index.html +3 -3
  17. package/dist/web/sw.js +1 -1
  18. package/package.json +1 -1
  19. package/snapshot-state.md +1526 -0
  20. package/src/index.ts +0 -0
  21. package/src/providers/claude-agent-sdk.ts +16 -14
  22. package/src/providers/mock-provider.ts +6 -1
  23. package/src/server/index.ts +3 -15
  24. package/src/server/routes/proxy.ts +46 -53
  25. package/src/server/ws/chat.ts +194 -139
  26. package/src/services/account-selector.service.ts +8 -6
  27. package/src/services/account.service.ts +1 -0
  28. package/src/services/claude-usage.service.ts +10 -4
  29. package/src/services/proxy.service.ts +4 -19
  30. package/src/types/api.ts +9 -1
  31. package/src/web/components/chat/chat-tab.tsx +14 -5
  32. package/src/web/components/chat/message-input.tsx +39 -12
  33. package/src/web/components/chat/message-list.tsx +15 -12
  34. package/src/web/components/layout/panel-layout.tsx +17 -1
  35. package/src/web/components/settings/proxy-settings-section.tsx +40 -42
  36. package/src/web/hooks/use-chat.ts +196 -203
  37. package/src/web/stores/panel-store.ts +10 -10
  38. package/test-tokens.mjs +212 -0
  39. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  40. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  41. package/dist/web/assets/chat-tab-cawT08fh.js +0 -7
  42. package/dist/web/assets/index-CpOYx0qg.js +0 -31
  43. package/dist/web/assets/index-WKLuYsBY.css +0 -2
  44. package/dist/web/assets/keybindings-store-vOnSm10D.js +0 -1
@@ -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
- ws: ChatWsSocket | null;
24
+ clients: Set<ChatWsSocket>;
20
25
  abort?: AbortController;
21
26
  projectPath?: string;
22
27
  projectName?: string;
23
- pingInterval?: ReturnType<typeof setInterval>;
24
- isStreaming: boolean;
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
- /** 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 */
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.ws) return true;
43
+ if (entry.clients.size > 0) return true;
44
44
  }
45
45
  return false;
46
46
  }
47
47
 
48
- /** Send event to FE if connected, silently drop otherwise */
49
- function safeSend(sessionId: string, event: unknown): void {
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?.ws) {
57
+ if (!entry || entry.clients.size === 0) {
52
58
  const evType = (event as any)?.type ?? "unknown";
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}`);
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
- entry.ws.send(JSON.stringify(event));
97
+ ws.send(JSON.stringify({ type: "turn_events", events: entry.turnEvents }));
61
98
  } catch (e) {
62
- console.warn(`[chat] session=${sessionId} safeSend: send failed (${(e as Error).message})`);
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
- if (entry.pingInterval) clearInterval(entry.pingInterval);
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 (ws=${entry.ws ? "connected" : "null"})`);
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.needsCatchUp = false;
97
- entry.catchUpText = "";
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
- // 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
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
- safeSend(sessionId, {
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
- safeSend(sessionId, { type: "streaming_status", status: "connecting", elapsed });
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
- // 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.
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
- safeSend(sessionId, { type: "streaming_status", status: "streaming" });
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
- safeSend(sessionId, { type: "title_updated", title });
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
- // 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
- }
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
- safeSend(sessionId, { type: "error", message: errMsg });
296
+ bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
251
297
  }
252
298
  } finally {
253
299
  if (heartbeat) clearInterval(heartbeat);
254
- // Always send done guarantees FE resets isStreaming even if provider didn't yield done
255
- safeSend(sessionId, { type: "done", sessionId, contextWindowPct: lastContextWindowPct });
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.needsCatchUp = false;
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 — replace ws, clear cleanup timer
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
- // If streaming, enter catch-up mode
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: "status",
349
+ type: "session_state",
316
350
  sessionId,
317
- isStreaming: existing.isStreaming,
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
- safeSend(sessionId, { type: "title_updated", title });
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 (streaming=${existing.isStreaming}, catchUp=${existing.needsCatchUp})`);
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 — 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, {
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
- pingInterval,
349
- isStreaming: false,
350
- needsCatchUp: false,
351
- catchUpText: "",
352
- });
353
- ws.send(JSON.stringify({ type: "connected", sessionId, sessionTitle: session?.title || null }));
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
- safeSend(sessionId, { type: "title_updated", title });
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 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)!;
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: "status",
458
+ type: "session_state",
414
459
  sessionId,
415
- isStreaming: entry.isStreaming,
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
- // If already streaming, abort current query first and wait for cleanup
447
- if (entry.isStreaming && entry.abort) {
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, entry.permissionMode).then(resolve, resolve);
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) entry.pendingApprovalEvent = undefined;
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
- 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})`);
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 (!entry.isStreaming) {
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, 1.0)
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
- * - 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)
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), 1.0);
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 };
@@ -530,6 +530,7 @@ class AccountService {
530
530
  client_id: OAUTH_CLIENT_ID,
531
531
  refresh_token: account.refreshToken,
532
532
  }),
533
+ signal: AbortSignal.timeout(15_000),
533
534
  });
534
535
  if (!res.ok) {
535
536
  const errorBody = await res.text().catch(() => "");
@@ -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
- // Use recursive setTimeout instead of setInterval to prevent overlap
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
- await pollOnce();
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 {