claudeck 1.3.0 → 1.4.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.
@@ -42,6 +42,37 @@ import { runAgent } from "./agent-loop.js";
42
42
  import { runOrchestrator } from "./orchestrator.js";
43
43
  import { runDag } from "./dag-executor.js";
44
44
 
45
+ // ── Session broadcast rooms ───────────────────────────────────────────────
46
+ // Maps sessionId → Set of WebSocket clients watching that session
47
+ const sessionRooms = new Map();
48
+
49
+ function joinRoom(sessionId, ws) {
50
+ if (!sessionRooms.has(sessionId)) sessionRooms.set(sessionId, new Set());
51
+ sessionRooms.get(sessionId).add(ws);
52
+ }
53
+
54
+ function leaveRoom(ws) {
55
+ for (const [sessionId, clients] of sessionRooms) {
56
+ clients.delete(ws);
57
+ if (clients.size === 0) sessionRooms.delete(sessionId);
58
+ }
59
+ }
60
+
61
+ function broadcastToSession(sessionId, message, excludeWs = null) {
62
+ const clients = sessionRooms.get(sessionId);
63
+ if (!clients) return;
64
+ const payload = JSON.stringify({ ...message, _broadcast: true });
65
+ for (const client of clients) {
66
+ if (client !== excludeWs && client.readyState === 1) {
67
+ client.send(payload);
68
+ }
69
+ }
70
+ }
71
+
72
+ // Global pending approvals — enables cross-connection approval (any client can approve)
73
+ // Key: approval ID, Value: { resolve, timer, toolInput, ws, localMap, sessionId }
74
+ const globalPendingApprovals = new Map();
75
+
45
76
  // Tools that are read-only and safe to auto-approve in "confirmDangerous" mode
46
77
  export const READ_ONLY_TOOLS = new Set([
47
78
  "Read", "Glob", "Grep", "WebSearch", "WebFetch", "Agent",
@@ -87,7 +118,7 @@ export function getActiveSessionIds() {
87
118
  * Creates a canUseTool callback that sends permission requests over WebSocket
88
119
  * AND Telegram (for AFK approval). Whichever channel responds first wins.
89
120
  */
90
- export function makeCanUseTool(ws, pendingApprovals, permissionMode, chatId, sessionTitle) {
121
+ export function makeCanUseTool(ws, pendingApprovals, permissionMode, chatId, sessionTitle, getSessionId = null) {
91
122
  return async (toolName, toolInput, options) => {
92
123
  // Bypass mode — auto-approve everything
93
124
  if (permissionMode === "bypass") {
@@ -109,6 +140,8 @@ export function makeCanUseTool(ws, pendingApprovals, permissionMode, chatId, ses
109
140
  }
110
141
 
111
142
  ws.send(JSON.stringify(payload));
143
+ const permSid = getSessionId?.();
144
+ if (permSid) broadcastToSession(permSid, payload, ws);
112
145
 
113
146
  // Also send to Telegram for AFK approval
114
147
  if (telegramEnabled()) {
@@ -122,8 +155,11 @@ export function makeCanUseTool(ws, pendingApprovals, permissionMode, chatId, ses
122
155
  const timeoutMs = getApprovalTimeoutMs();
123
156
 
124
157
  return new Promise((resolve) => {
158
+ const permSidForApproval = getSessionId?.();
159
+
125
160
  const timer = setTimeout(() => {
126
161
  pendingApprovals.delete(id);
162
+ globalPendingApprovals.delete(id);
127
163
  markTelegramMessageResolved(id, "timeout").catch(() => {});
128
164
  resolve({ behavior: "deny", message: `Approval timed out (${Math.round(timeoutMs / 60000)}min)` });
129
165
  }, timeoutMs);
@@ -133,12 +169,14 @@ export function makeCanUseTool(ws, pendingApprovals, permissionMode, chatId, ses
133
169
  options.signal.addEventListener("abort", () => {
134
170
  clearTimeout(timer);
135
171
  pendingApprovals.delete(id);
172
+ globalPendingApprovals.delete(id);
136
173
  markTelegramMessageResolved(id, "abort").catch(() => {});
137
174
  resolve({ behavior: "deny", message: "Aborted by user" });
138
175
  }, { once: true });
139
176
  }
140
177
 
141
178
  pendingApprovals.set(id, { resolve, timer, toolInput, ws });
179
+ globalPendingApprovals.set(id, { resolve, timer, toolInput, ws, localMap: pendingApprovals, sessionId: permSidForApproval });
142
180
  });
143
181
  };
144
182
  }
@@ -164,17 +202,17 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
164
202
  const sKey = chatId ? `${ourSid}::${chatId}` : ourSid;
165
203
  sessionIds.set(sKey, claudeSessionId);
166
204
 
167
- if (!getSession(ourSid)) {
168
- createSession(ourSid, claudeSessionId, projectName || "Session", cwd || "");
205
+ if (!await getSession(ourSid)) {
206
+ await createSession(ourSid, claudeSessionId, projectName || "Session", cwd || "");
169
207
  if (isWorkflow) {
170
- updateSessionTitle(ourSid, `Workflow: ${stepLabel}`);
208
+ await updateSessionTitle(ourSid, `Workflow: ${stepLabel}`);
171
209
  }
172
210
  } else {
173
- updateClaudeSessionId(ourSid, claudeSessionId);
211
+ await updateClaudeSessionId(ourSid, claudeSessionId);
174
212
  }
175
213
 
176
214
  if (chatId) {
177
- setClaudeSession(ourSid, chatId, claudeSessionId);
215
+ await setClaudeSession(ourSid, chatId, claudeSessionId);
178
216
  }
179
217
 
180
218
  wsSend({ type: "session", sessionId: ourSid });
@@ -185,12 +223,12 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
185
223
  // user message saved by caller for chat; for workflow, save with step label
186
224
  }
187
225
  if (isWorkflow) {
188
- addMessage(resolvedSid, "user", JSON.stringify({ text: msgText }), null, wfMeta);
226
+ await addMessage(resolvedSid, "user", JSON.stringify({ text: msgText }), null, wfMeta);
189
227
  }
190
228
 
191
229
  if (!isWorkflow) {
192
230
  // Auto-set session title from first user message
193
- const existingSession = getSession(ourSid);
231
+ const existingSession = await getSession(ourSid);
194
232
  if (existingSession && !existingSession.title) {
195
233
  // Title is set by caller
196
234
  }
@@ -204,12 +242,12 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
204
242
  if (block.type === "text" && block.text) {
205
243
  wsSend({ type: "text", text: block.text });
206
244
  if (resolvedSid) {
207
- addMessage(resolvedSid, "assistant", JSON.stringify({ text: block.text }), chatId || null, wfMeta);
245
+ await addMessage(resolvedSid, "assistant", JSON.stringify({ text: block.text }), chatId || null, wfMeta);
208
246
  }
209
247
  } else if (block.type === "tool_use") {
210
248
  wsSend({ type: "tool", id: block.id, name: block.name, input: block.input });
211
249
  if (resolvedSid) {
212
- addMessage(resolvedSid, "tool", JSON.stringify({ id: block.id, name: block.name, input: block.input }), chatId || null, wfMeta);
250
+ await addMessage(resolvedSid, "tool", JSON.stringify({ id: block.id, name: block.name, input: block.input }), chatId || null, wfMeta);
213
251
  }
214
252
  }
215
253
  }
@@ -231,7 +269,7 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
231
269
  ([, v]) => v === claudeSessionId
232
270
  )?.[0];
233
271
  if (sid) {
234
- addCost(sid, costUsd, durationMs, numTurns, inputTokens, outputTokens, { model, stopReason: "success", isError: 0, cacheReadTokens, cacheCreationTokens });
272
+ await addCost(sid, costUsd, durationMs, numTurns, inputTokens, outputTokens, { model, stopReason: "success", isError: 0, cacheReadTokens, cacheCreationTokens });
235
273
  }
236
274
 
237
275
  wsSend({
@@ -239,7 +277,7 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
239
277
  duration_ms: sdkMsg.duration_ms,
240
278
  num_turns: sdkMsg.num_turns,
241
279
  cost_usd: sdkMsg.total_cost_usd,
242
- totalCost: getTotalCost(),
280
+ totalCost: await getTotalCost(),
243
281
  input_tokens: inputTokens,
244
282
  output_tokens: outputTokens,
245
283
  cache_read_tokens: cacheReadTokens,
@@ -251,7 +289,7 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
251
289
  lastMetrics = { durationMs, costUsd, inputTokens, outputTokens, model, turns: numTurns, isError: false };
252
290
 
253
291
  if (resolvedSid) {
254
- addMessage(resolvedSid, "result", JSON.stringify({
292
+ await addMessage(resolvedSid, "result", JSON.stringify({
255
293
  duration_ms: sdkMsg.duration_ms,
256
294
  num_turns: sdkMsg.num_turns,
257
295
  cost_usd: sdkMsg.total_cost_usd,
@@ -273,8 +311,8 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
273
311
  ([, v]) => v === claudeSessionId
274
312
  )?.[0];
275
313
  if (sid) {
276
- addCost(sid, costUsd, durationMs, numTurns, inputTokens, outputTokens, { model, stopReason: sdkMsg.subtype, isError: 1, cacheReadTokens, cacheCreationTokens });
277
- addMessage(sid, "error", JSON.stringify({ error: errMsg, subtype: sdkMsg.subtype, duration_ms: durationMs, cost_usd: costUsd, model }), chatId || null, wfMeta);
314
+ await addCost(sid, costUsd, durationMs, numTurns, inputTokens, outputTokens, { model, stopReason: sdkMsg.subtype, isError: 1, cacheReadTokens, cacheCreationTokens });
315
+ await addMessage(sid, "error", JSON.stringify({ error: errMsg, subtype: sdkMsg.subtype, duration_ms: durationMs, cost_usd: costUsd, model }), chatId || null, wfMeta);
278
316
  }
279
317
  lastMetrics = { durationMs, costUsd, inputTokens, outputTokens, model, turns: numTurns, isError: true, error: errMsg };
280
318
  wsSend({ type: "error", error: errMsg });
@@ -303,7 +341,7 @@ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, c
303
341
  content: text.slice(0, 10000),
304
342
  isError: block.is_error || false,
305
343
  };
306
- addMessage(resolvedSid, "tool_result", JSON.stringify(dbPayload), chatId || null, wfMeta);
344
+ await addMessage(resolvedSid, "tool_result", JSON.stringify(dbPayload), chatId || null, wfMeta);
307
345
  }
308
346
  }
309
347
  }
@@ -344,6 +382,7 @@ export function handleClose({ activeQueries, pendingApprovals }) {
344
382
  for (const [id, { resolve, timer }] of pendingApprovals) {
345
383
  clearTimeout(timer);
346
384
  resolve({ behavior: "deny", message: "Client disconnected" });
385
+ globalPendingApprovals.delete(id);
347
386
  }
348
387
  pendingApprovals.clear();
349
388
  }
@@ -361,21 +400,48 @@ export function handleAbort(msg, { activeQueries, pendingApprovals }) {
361
400
  for (const [id, { resolve, timer }] of pendingApprovals) {
362
401
  clearTimeout(timer);
363
402
  resolve({ behavior: "deny", message: "Aborted by user" });
403
+ globalPendingApprovals.delete(id);
364
404
  }
365
405
  pendingApprovals.clear();
366
406
  }
367
407
 
368
408
  // ── Extracted handler: permission response ────────────────────────────────
369
- export function handlePermissionResponse(msg, { pendingApprovals }) {
370
- const pending = pendingApprovals.get(msg.id);
409
+ export function handlePermissionResponse(msg, { pendingApprovals, ws: responderWs }) {
410
+ // Check local first (same connection that initiated the request)
411
+ let pending = pendingApprovals.get(msg.id);
412
+ let isLocal = !!pending;
413
+
414
+ // If not found locally, check global (cross-connection approval from another client)
415
+ if (!pending) {
416
+ pending = globalPendingApprovals.get(msg.id);
417
+ }
418
+
371
419
  if (pending) {
420
+ // Get sessionId before deleting (local pending doesn't have it, global does)
421
+ const sessionId = pending.sessionId || globalPendingApprovals.get(msg.id)?.sessionId;
422
+
372
423
  clearTimeout(pending.timer);
373
424
  pendingApprovals.delete(msg.id);
425
+ globalPendingApprovals.delete(msg.id);
426
+ // Also clean up from the originating connection's local map
427
+ if (!isLocal && pending.localMap) pending.localMap.delete(msg.id);
428
+
374
429
  if (msg.behavior === "allow") {
375
430
  pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
376
431
  } else {
377
432
  pending.resolve({ behavior: "deny", message: "Denied by user" });
378
433
  }
434
+
435
+ // Broadcast permission_response_external to dismiss modals on all other clients
436
+ if (sessionId) {
437
+ broadcastToSession(sessionId, {
438
+ type: "permission_response_external",
439
+ id: msg.id,
440
+ behavior: msg.behavior,
441
+ source: "broadcast",
442
+ }, responderWs);
443
+ }
444
+
379
445
  // Update Telegram message to show it was resolved via web
380
446
  markTelegramMessageResolved(msg.id, msg.behavior === "allow" ? "allow" : "deny").catch(() => {});
381
447
  }
@@ -389,6 +455,7 @@ export async function handleWorkflow(msg, { ws, sessionIds, activeQueries, pendi
389
455
  function wfSend(payload) {
390
456
  if (ws.readyState !== 1) return;
391
457
  ws.send(JSON.stringify(payload));
458
+ if (clientSid) broadcastToSession(clientSid, payload, ws);
392
459
  }
393
460
 
394
461
  wfSend({ type: "workflow_started", workflow: { id: workflow.id, title: workflow.title, steps: workflow.steps.map((s) => s.label) } });
@@ -421,10 +488,11 @@ export async function handleWorkflow(msg, { ws, sessionIds, activeQueries, pendi
421
488
  abortController,
422
489
  maxTurns: 30,
423
490
  executable: execPath,
491
+ settingSources: ["user", "project", "local"],
424
492
  };
425
493
 
426
494
  if (!useBypass && !usePlan) {
427
- stepOpts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, null, `Workflow: ${workflow.title}`);
495
+ stepOpts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, null, `Workflow: ${workflow.title}`, () => clientSid);
428
496
  }
429
497
  if (wfModel) stepOpts.model = resolveModel(wfModel);
430
498
 
@@ -511,6 +579,7 @@ export async function handleAgentChain(msg, { ws, sessionIds, activeQueries, pen
511
579
  function chainSend(payload) {
512
580
  if (ws.readyState !== 1) return;
513
581
  ws.send(JSON.stringify(payload));
582
+ if (clientSid) broadcastToSession(clientSid, payload, ws);
514
583
  }
515
584
 
516
585
  chainSend({
@@ -627,7 +696,9 @@ export async function handleOrchestrate(msg, { ws, sessionIds, activeQueries, pe
627
696
  try {
628
697
  agents = JSON.parse(await readFile(configPath("agents.json"), "utf-8"));
629
698
  } catch {
630
- ws.send(JSON.stringify({ type: "error", error: "Failed to load agents" }));
699
+ const errPayload = { type: "error", error: "Failed to load agents" };
700
+ ws.send(JSON.stringify(errPayload));
701
+ if (clientSid) broadcastToSession(clientSid, errPayload, ws);
631
702
  return;
632
703
  }
633
704
 
@@ -653,11 +724,13 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
653
724
 
654
725
  // Handle /remember command — save memory and respond without calling Claude
655
726
  if (message && message.trim().toLowerCase().startsWith('/remember ') && cwd) {
656
- const result = parseRememberCommand(message, cwd, clientSid);
727
+ const result = await parseRememberCommand(message, cwd, clientSid);
657
728
  function remSend(payload) {
658
729
  if (ws.readyState !== 1) return;
659
730
  if (chatId) payload.chatId = chatId;
731
+ if (clientSid) payload.sessionId = clientSid;
660
732
  ws.send(JSON.stringify(payload));
733
+ if (clientSid) broadcastToSession(clientSid, payload, ws);
661
734
  }
662
735
  if (result) {
663
736
  remSend({ type: "text", text: result.saved
@@ -676,8 +749,8 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
676
749
  const sessionKey = chatId ? `${clientSid}::${chatId}` : clientSid;
677
750
  const resumeId = clientSid ? sessionIds.get(sessionKey) : undefined;
678
751
 
679
- if (clientSid && getSession(clientSid)) {
680
- touchSession(clientSid);
752
+ if (clientSid && await getSession(clientSid)) {
753
+ await touchSession(clientSid);
681
754
  }
682
755
 
683
756
  const abortController = new AbortController();
@@ -700,7 +773,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
700
773
  const wtResult = await createWorktree(cwd, branchName);
701
774
  const wtId = crypto.randomUUID();
702
775
 
703
- createWorktreeRecord(wtId, clientSid || null, cwd, wtResult.worktreePath, branchName, baseBranch, (message || "").slice(0, 200));
776
+ await createWorktreeRecord(wtId, clientSid || null, cwd, wtResult.worktreePath, branchName, baseBranch, (message || "").slice(0, 200));
704
777
  worktreeRecord = { id: wtId, worktreePath: wtResult.worktreePath, branchName, baseBranch };
705
778
  effectiveCwd = wtResult.worktreePath;
706
779
 
@@ -709,6 +782,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
709
782
  const wtPayload = { type: "worktree_created", worktreeId: wtId, branchName, baseBranch, worktreePath: wtResult.worktreePath };
710
783
  if (chatId) wtPayload.chatId = chatId;
711
784
  ws.send(JSON.stringify(wtPayload));
785
+ if (clientSid) broadcastToSession(clientSid, wtPayload, ws);
712
786
  }
713
787
  } catch (err) {
714
788
  console.error("Worktree creation failed:", err.message, err.stack);
@@ -730,11 +804,12 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
730
804
  abortController,
731
805
  executable: execPath,
732
806
  stderr: (text) => stderrChunks.push(text),
807
+ settingSources: ["user", "project", "local"],
733
808
  };
734
809
  if (effectiveMaxTurns) opts.maxTurns = effectiveMaxTurns;
735
810
 
736
811
  if (!useBypass && !usePlan) {
737
- opts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, chatId, projectName || "Chat");
812
+ opts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, chatId, projectName || "Chat", () => state.resolvedSid);
738
813
  }
739
814
  if (chatModel) opts.model = resolveModel(chatModel);
740
815
  if (Array.isArray(disabledTools) && disabledTools.length > 0) {
@@ -752,10 +827,10 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
752
827
  (opts.appendSystemPrompt ? '\n\n' : '') + systemPrompt;
753
828
  }
754
829
  // Run memory maintenance (decay stale, clean expired) on each session
755
- if (cwd) runMaintenance(cwd);
830
+ if (cwd) await runMaintenance(cwd);
756
831
  // Inject persistent memories for this project (smart: uses user message for relevance)
757
832
  if (cwd) {
758
- const { prompt: memPrompt, count: memCount, memories: memList } = buildMemoryPrompt(cwd, 10, message);
833
+ const { prompt: memPrompt, count: memCount, memories: memList } = await buildMemoryPrompt(cwd, 10, message);
759
834
  if (memPrompt) {
760
835
  opts.appendSystemPrompt = (opts.appendSystemPrompt || '') +
761
836
  (opts.appendSystemPrompt ? '\n\n' : '') + memPrompt;
@@ -776,6 +851,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
776
851
  const payload = { type: "memories_injected", count: memCount, memories: memList };
777
852
  if (chatId) payload.chatId = chatId;
778
853
  ws.send(JSON.stringify(payload));
854
+ if (clientSid) broadcastToSession(clientSid, payload, ws);
779
855
  }
780
856
  } else {
781
857
  console.log(`\n══════ MEMORY INJECTION ══════`);
@@ -793,6 +869,8 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
793
869
  if (chatId) payload.chatId = chatId;
794
870
  if (state.resolvedSid) payload.sessionId = state.resolvedSid;
795
871
  ws.send(JSON.stringify(payload));
872
+ // Broadcast to other clients watching this session
873
+ if (state.resolvedSid) broadcastToSession(state.resolvedSid, payload, ws);
796
874
  }
797
875
 
798
876
  // Register for global tracking if we already know the session
@@ -817,14 +895,14 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
817
895
  const sKey = chatId ? `${ourSid}::${chatId}` : ourSid;
818
896
  sessionIds.set(sKey, claudeSessionId);
819
897
 
820
- if (!getSession(ourSid)) {
821
- createSession(ourSid, claudeSessionId, projectName || "Session", cwd || "");
898
+ if (!await getSession(ourSid)) {
899
+ await createSession(ourSid, claudeSessionId, projectName || "Session", cwd || "");
822
900
  } else {
823
- updateClaudeSessionId(ourSid, claudeSessionId);
901
+ await updateClaudeSessionId(ourSid, claudeSessionId);
824
902
  }
825
903
 
826
904
  if (chatId) {
827
- setClaudeSession(ourSid, chatId, claudeSessionId);
905
+ await setClaudeSession(ourSid, chatId, claudeSessionId);
828
906
  }
829
907
 
830
908
  wsSend({ type: "session", sessionId: ourSid });
@@ -832,15 +910,21 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
832
910
  if (images?.length) {
833
911
  userMsgData.images = images.map(i => ({ name: i.name, data: i.data, mimeType: i.mimeType }));
834
912
  }
835
- addMessage(state.resolvedSid, "user", JSON.stringify(userMsgData), chatId || null);
913
+ await addMessage(state.resolvedSid, "user", JSON.stringify(userMsgData), chatId || null);
914
+
915
+ // Broadcast user message to observers (sender already rendered it locally)
916
+ const userBroadcast = { type: "user_message", text: message, sessionId: state.resolvedSid };
917
+ if (chatId) userBroadcast.chatId = chatId;
918
+ if (images?.length) userBroadcast.images = images.map(i => ({ name: i.name, mimeType: i.mimeType }));
919
+ broadcastToSession(state.resolvedSid, userBroadcast, ws);
836
920
 
837
921
  // Register global query tracking now that we know the session
838
922
  if (!clientSid) registerGlobalQuery(state.resolvedSid, queryKey);
839
923
 
840
- const existingSession = getSession(ourSid);
924
+ const existingSession = await getSession(ourSid);
841
925
  if (existingSession && !existingSession.title) {
842
926
  const title = message.slice(0, 100).split("\n")[0];
843
- updateSessionTitle(ourSid, title);
927
+ await updateSessionTitle(ourSid, title);
844
928
  }
845
929
  continue;
846
930
  }
@@ -850,10 +934,10 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
850
934
  if (block.type === "text" && block.text) {
851
935
  state.lastAssistantText += (state.lastAssistantText ? "\n\n" : "") + block.text;
852
936
  wsSend({ type: "text", text: block.text });
853
- if (state.resolvedSid) addMessage(state.resolvedSid, "assistant", JSON.stringify({ text: block.text }), chatId || null);
937
+ if (state.resolvedSid) await addMessage(state.resolvedSid, "assistant", JSON.stringify({ text: block.text }), chatId || null);
854
938
  } else if (block.type === "tool_use") {
855
939
  wsSend({ type: "tool", id: block.id, name: block.name, input: block.input });
856
- if (state.resolvedSid) addMessage(state.resolvedSid, "tool", JSON.stringify({ id: block.id, name: block.name, input: block.input }), chatId || null);
940
+ if (state.resolvedSid) await addMessage(state.resolvedSid, "tool", JSON.stringify({ id: block.id, name: block.name, input: block.input }), chatId || null);
857
941
  }
858
942
  }
859
943
  continue;
@@ -867,10 +951,10 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
867
951
  const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
868
952
  const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
869
953
  const model = Object.keys(sdkMsg.modelUsage || {})[0] || sessionModel;
870
- if (sid) addCost(sid, sdkMsg.total_cost_usd || 0, sdkMsg.duration_ms || 0, sdkMsg.num_turns || 0, inputTokens, outputTokens, { model, stopReason: "success", isError: 0, cacheReadTokens, cacheCreationTokens });
871
- wsSend({ type: "result", duration_ms: sdkMsg.duration_ms, num_turns: sdkMsg.num_turns, cost_usd: sdkMsg.total_cost_usd, totalCost: getTotalCost(), input_tokens: inputTokens, output_tokens: outputTokens, cache_read_tokens: cacheReadTokens, cache_creation_tokens: cacheCreationTokens, model, stop_reason: "success" });
954
+ if (sid) await addCost(sid, sdkMsg.total_cost_usd || 0, sdkMsg.duration_ms || 0, sdkMsg.num_turns || 0, inputTokens, outputTokens, { model, stopReason: "success", isError: 0, cacheReadTokens, cacheCreationTokens });
955
+ wsSend({ type: "result", duration_ms: sdkMsg.duration_ms, num_turns: sdkMsg.num_turns, cost_usd: sdkMsg.total_cost_usd, totalCost: await getTotalCost(), input_tokens: inputTokens, output_tokens: outputTokens, cache_read_tokens: cacheReadTokens, cache_creation_tokens: cacheCreationTokens, model, stop_reason: "success" });
872
956
  state.lastChatMetrics = { durationMs: sdkMsg.duration_ms, costUsd: sdkMsg.total_cost_usd, inputTokens, outputTokens, model, turns: sdkMsg.num_turns, isError: false };
873
- if (state.resolvedSid) addMessage(state.resolvedSid, "result", JSON.stringify({ duration_ms: sdkMsg.duration_ms, num_turns: sdkMsg.num_turns, cost_usd: sdkMsg.total_cost_usd, input_tokens: inputTokens, output_tokens: outputTokens, cache_read_tokens: cacheReadTokens, cache_creation_tokens: cacheCreationTokens, model, stop_reason: "success" }), chatId || null);
957
+ if (state.resolvedSid) await addMessage(state.resolvedSid, "result", JSON.stringify({ duration_ms: sdkMsg.duration_ms, num_turns: sdkMsg.num_turns, cost_usd: sdkMsg.total_cost_usd, input_tokens: inputTokens, output_tokens: outputTokens, cache_read_tokens: cacheReadTokens, cache_creation_tokens: cacheCreationTokens, model, stop_reason: "success" }), chatId || null);
874
958
  } else if (sdkMsg.subtype === "error_max_turns") {
875
959
  // Max turns reached — treat as a normal completion with a notice
876
960
  const sid = state.resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
@@ -879,8 +963,8 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
879
963
  const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
880
964
  const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
881
965
  const model = Object.keys(sdkMsg.modelUsage || {})[0] || sessionModel;
882
- if (sid) addCost(sid, sdkMsg.total_cost_usd || 0, sdkMsg.duration_ms || 0, sdkMsg.num_turns || 0, inputTokens, outputTokens, { model, stopReason: "error_max_turns", isError: 0, cacheReadTokens, cacheCreationTokens });
883
- wsSend({ type: "result", duration_ms: sdkMsg.duration_ms, num_turns: sdkMsg.num_turns, cost_usd: sdkMsg.total_cost_usd, totalCost: getTotalCost(), input_tokens: inputTokens, output_tokens: outputTokens, cache_read_tokens: cacheReadTokens, cache_creation_tokens: cacheCreationTokens, model, stop_reason: "error_max_turns" });
966
+ if (sid) await addCost(sid, sdkMsg.total_cost_usd || 0, sdkMsg.duration_ms || 0, sdkMsg.num_turns || 0, inputTokens, outputTokens, { model, stopReason: "error_max_turns", isError: 0, cacheReadTokens, cacheCreationTokens });
967
+ wsSend({ type: "result", duration_ms: sdkMsg.duration_ms, num_turns: sdkMsg.num_turns, cost_usd: sdkMsg.total_cost_usd, totalCost: await getTotalCost(), input_tokens: inputTokens, output_tokens: outputTokens, cache_read_tokens: cacheReadTokens, cache_creation_tokens: cacheCreationTokens, model, stop_reason: "error_max_turns" });
884
968
  wsSend({ type: "error", error: `Reached max turns limit (${sdkMsg.num_turns}). Send another message to continue.` });
885
969
  } else if (sdkMsg.subtype?.startsWith("error")) {
886
970
  const errMsg = sdkMsg.errors?.join(", ") || sdkMsg.error || sdkMsg.message || "Unknown error";
@@ -896,8 +980,8 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
896
980
  const sid = state.resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
897
981
  state.lastChatMetrics = { durationMs, costUsd, inputTokens, outputTokens, model, turns: numTurns, isError: true, error: errMsg };
898
982
  if (sid) {
899
- addCost(sid, costUsd, durationMs, numTurns, inputTokens, outputTokens, { model, stopReason: sdkMsg.subtype, isError: 1, cacheReadTokens, cacheCreationTokens });
900
- addMessage(sid, "error", JSON.stringify({ error: errMsg, subtype: sdkMsg.subtype, duration_ms: durationMs, cost_usd: costUsd, model }), chatId || null);
983
+ await addCost(sid, costUsd, durationMs, numTurns, inputTokens, outputTokens, { model, stopReason: sdkMsg.subtype, isError: 1, cacheReadTokens, cacheCreationTokens });
984
+ await addMessage(sid, "error", JSON.stringify({ error: errMsg, subtype: sdkMsg.subtype, duration_ms: durationMs, cost_usd: costUsd, model }), chatId || null);
901
985
  }
902
986
  wsSend({ type: "error", error: errMsg });
903
987
  }
@@ -913,7 +997,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
913
997
  wsSend({ type: "tool_result", ...wirePayload });
914
998
  if (state.resolvedSid) {
915
999
  const dbPayload = { toolUseId: block.tool_use_id, content: text.slice(0, 10000), isError: block.is_error || false };
916
- addMessage(state.resolvedSid, "tool_result", JSON.stringify(dbPayload), chatId || null);
1000
+ await addMessage(state.resolvedSid, "tool_result", JSON.stringify(dbPayload), chatId || null);
917
1001
  }
918
1002
  }
919
1003
  }
@@ -927,7 +1011,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
927
1011
  wsSend({ type: "done" });
928
1012
  } catch (err) {
929
1013
  if (err.name === "AbortError") {
930
- if (state.resolvedSid) addMessage(state.resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
1014
+ if (state.resolvedSid) await addMessage(state.resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
931
1015
  wsSend({ type: "aborted" });
932
1016
  } else {
933
1017
  const stderrOutput = stderrChunks.join("");
@@ -942,7 +1026,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
942
1026
  wsSend({ type: "done" });
943
1027
  } catch (retryErr) {
944
1028
  if (retryErr.name === "AbortError") {
945
- if (state.resolvedSid) addMessage(state.resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
1029
+ if (state.resolvedSid) await addMessage(state.resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
946
1030
  wsSend({ type: "aborted" });
947
1031
  } else {
948
1032
  console.error("Query retry error:", retryErr.message);
@@ -958,7 +1042,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
958
1042
  activeQueries.delete(queryKey);
959
1043
  unregisterGlobalQuery(state.resolvedSid, queryKey);
960
1044
  // Send push notification when query completes
961
- const session = state.resolvedSid ? getSession(state.resolvedSid) : null;
1045
+ const session = state.resolvedSid ? await getSession(state.resolvedSid) : null;
962
1046
  const pushTitle = session?.title || "Session complete";
963
1047
  sendPushNotification("Claudeck", pushTitle, `chat-${state.resolvedSid}`);
964
1048
 
@@ -1006,10 +1090,10 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
1006
1090
  if (cwd && state.lastAssistantText) {
1007
1091
  try {
1008
1092
  // 1. Parse explicit ```memory blocks (Claude-requested saves)
1009
- const explicitCount = saveExplicitMemories(cwd, state.lastAssistantText, state.resolvedSid);
1093
+ const explicitCount = await saveExplicitMemories(cwd, state.lastAssistantText, state.resolvedSid);
1010
1094
 
1011
1095
  // 2. Heuristic extraction from assistant text
1012
- const autoCount = captureMemories(cwd, state.lastAssistantText, state.resolvedSid, null);
1096
+ const autoCount = await captureMemories(cwd, state.lastAssistantText, state.resolvedSid, null);
1013
1097
 
1014
1098
  const totalCaptured = explicitCount + autoCount;
1015
1099
  if (totalCaptured > 0) {
@@ -1019,6 +1103,7 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
1019
1103
  const payload = { type: "memories_captured", count: totalCaptured, explicit: explicitCount, auto: autoCount };
1020
1104
  if (chatId) payload.chatId = chatId;
1021
1105
  ws.send(JSON.stringify(payload));
1106
+ if (state.resolvedSid) broadcastToSession(state.resolvedSid, payload, ws);
1022
1107
  }
1023
1108
  }
1024
1109
  } catch (e) { console.error("Memory capture error:", e.message); }
@@ -1027,13 +1112,13 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
1027
1112
  // Worktree post-completion: auto-commit, diff stats, notify
1028
1113
  if (worktreeRecord) {
1029
1114
  try {
1030
- if (state.resolvedSid) updateWorktreeSession(worktreeRecord.id, state.resolvedSid);
1115
+ if (state.resolvedSid) await updateWorktreeSession(worktreeRecord.id, state.resolvedSid);
1031
1116
 
1032
1117
  const commitMsg = `claudeck: ${(message || "worktree changes").slice(0, 72)}`;
1033
1118
  await autoCommitWorktree(worktreeRecord.worktreePath, commitMsg);
1034
1119
 
1035
1120
  const stats = await getWorktreeDiffStats(worktreeRecord.worktreePath, worktreeRecord.baseBranch);
1036
- updateWorktreeStatus(worktreeRecord.id, "completed");
1121
+ await updateWorktreeStatus(worktreeRecord.id, "completed");
1037
1122
 
1038
1123
  if (ws.readyState === 1) {
1039
1124
  const wtPayload = {
@@ -1044,9 +1129,10 @@ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingAp
1044
1129
  };
1045
1130
  if (chatId) wtPayload.chatId = chatId;
1046
1131
  ws.send(JSON.stringify(wtPayload));
1132
+ if (state.resolvedSid) broadcastToSession(state.resolvedSid, wtPayload, ws);
1047
1133
  }
1048
1134
 
1049
- logNotification(
1135
+ await logNotification(
1050
1136
  "worktree",
1051
1137
  `Worktree "${worktreeRecord.branchName}" ready`,
1052
1138
  `+${stats.insertions} -${stats.deletions} lines in ${stats.files} file(s)`,
@@ -1066,12 +1152,26 @@ export function setupWebSocket(wss, sessionIds) {
1066
1152
  wss.on("connection", (ws) => {
1067
1153
  const ctx = { ws, sessionIds, activeQueries: new Map(), pendingApprovals: new Map() };
1068
1154
 
1069
- ws.on("close", () => handleClose(ctx));
1155
+ ws.on("close", () => {
1156
+ leaveRoom(ws);
1157
+ handleClose(ctx);
1158
+ });
1070
1159
 
1071
1160
  ws.on("message", async (raw) => {
1072
1161
  let msg;
1073
1162
  try { msg = JSON.parse(raw); } catch { return; }
1074
1163
 
1164
+ // Session broadcast: subscribe/unsubscribe
1165
+ if (msg.type === "subscribe") {
1166
+ leaveRoom(ws); // leave any previous room first
1167
+ if (msg.sessionId) joinRoom(msg.sessionId, ws);
1168
+ return;
1169
+ }
1170
+ if (msg.type === "unsubscribe") {
1171
+ leaveRoom(ws);
1172
+ return;
1173
+ }
1174
+
1075
1175
  if (msg.type === "abort") return handleAbort(msg, ctx);
1076
1176
  if (msg.type === "permission_response") return handlePermissionResponse(msg, ctx);
1077
1177
  if (msg.type === "workflow") return handleWorkflow(msg, ctx);
package/server.js CHANGED
@@ -88,7 +88,7 @@ const sessionIds = new Map();
88
88
  for (const row of rows) {
89
89
  sessionIds.set(row.id, row.claude_session_id);
90
90
  }
91
- const csRows = allClaudeSessions();
91
+ const csRows = await allClaudeSessions();
92
92
  for (const row of csRows) {
93
93
  const key = row.chat_id ? `${row.session_id}::${row.chat_id}` : row.session_id;
94
94
  sessionIds.set(key, row.claude_session_id);
@@ -214,7 +214,7 @@ ${isAuthEnabled() ? ` \x1b[2m➜ Auth:\x1b[0m \x1b[33menabled\x1b[0m\n \x1
214
214
  });
215
215
 
216
216
  // Purge old notifications once per day
217
- setInterval(() => purgeOldNotifications(90), 24 * 60 * 60 * 1000);
217
+ setInterval(async () => { try { await purgeOldNotifications(90); } catch {} }, 24 * 60 * 60 * 1000);
218
218
 
219
219
  // Graceful shutdown
220
220
  process.on("SIGINT", () => {