@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/web/assets/chat-tab-BDyjEN8p.js +7 -0
  3. package/dist/web/assets/{code-editor-u6bm6bdq.js → code-editor-BmFI-Khj.js} +1 -1
  4. package/dist/web/assets/{database-viewer-BgPBW1bJ.js → database-viewer-Cb7tqJqX.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-Cho-kjse.js → diff-viewer-D_f9S4Ya.js} +1 -1
  6. package/dist/web/assets/{git-graph-CktRdFwt.js → git-graph-Co3a8y4i.js} +1 -1
  7. package/dist/web/assets/index-BAioKo_2.css +2 -0
  8. package/dist/web/assets/{index-CpOYx0qg.js → index-CqMDTnLp.js} +13 -7
  9. package/dist/web/assets/keybindings-store-CulLCWPX.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-3_CTktzg.js → markdown-renderer-xipSjvIr.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-CH0JfEQ9.js → postgres-viewer-p5tz14oN.js} +1 -1
  12. package/dist/web/assets/{settings-tab-BI0n39LJ.js → settings-tab-CCz5ftre.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-DJyT7YZg.js → sqlite-viewer-vF9L6tfk.js} +1 -1
  14. package/dist/web/assets/{terminal-tab-OqCohyF0.js → terminal-tab-XbV1JFTB.js} +1 -1
  15. package/dist/web/index.html +2 -2
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/snapshot-state.md +1526 -0
  19. package/src/index.ts +0 -0
  20. package/src/server/index.ts +3 -15
  21. package/src/server/routes/proxy.ts +46 -53
  22. package/src/server/ws/chat.ts +184 -139
  23. package/src/services/account-selector.service.ts +8 -6
  24. package/src/services/account.service.ts +1 -0
  25. package/src/services/claude-usage.service.ts +10 -4
  26. package/src/services/proxy.service.ts +4 -19
  27. package/src/types/api.ts +9 -1
  28. package/src/web/components/chat/chat-tab.tsx +14 -5
  29. package/src/web/components/chat/message-list.tsx +15 -12
  30. package/src/web/components/settings/proxy-settings-section.tsx +40 -42
  31. package/src/web/hooks/use-chat.ts +196 -203
  32. package/test-tokens.mjs +212 -0
  33. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  34. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  35. package/dist/web/assets/chat-tab-cawT08fh.js +0 -7
  36. package/dist/web/assets/index-WKLuYsBY.css +0 -2
  37. 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,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, 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
+ // 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
- safeSend(sessionId, { type: "streaming_status", status: "streaming" });
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
- safeSend(sessionId, { type: "title_updated", title });
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
- // 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
- }
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
- safeSend(sessionId, { type: "error", message: errMsg });
286
+ bufferAndBroadcast(sessionId, { type: "error", message: errMsg });
251
287
  }
252
288
  } finally {
253
289
  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 });
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.needsCatchUp = false;
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 — replace ws, clear cleanup timer
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
- // If streaming, enter catch-up mode
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: "status",
339
+ type: "session_state",
316
340
  sessionId,
317
- isStreaming: existing.isStreaming,
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
- safeSend(sessionId, { type: "title_updated", title });
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 (streaming=${existing.isStreaming}, catchUp=${existing.needsCatchUp})`);
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 — 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, {
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
- pingInterval,
349
- isStreaming: false,
350
- needsCatchUp: false,
351
- catchUpText: "",
352
- });
353
- ws.send(JSON.stringify({ type: "connected", sessionId, sessionTitle: session?.title || null }));
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
- safeSend(sessionId, { type: "title_updated", title });
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 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)!;
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: "status",
448
+ type: "session_state",
414
449
  sessionId,
415
- isStreaming: entry.isStreaming,
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
- // If already streaming, abort current query first and wait for cleanup
447
- if (entry.isStreaming && entry.abort) {
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, entry.permissionMode).then(resolve, resolve);
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) entry.pendingApprovalEvent = undefined;
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
- 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})`);
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 (!entry.isStreaming) {
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, 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 {