claudeck 1.1.1 → 1.2.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/README.md +30 -4
  2. package/config/skillsmp-config.json +5 -0
  3. package/db.js +248 -0
  4. package/package.json +11 -2
  5. package/public/css/panels/git-panel.css +220 -0
  6. package/public/css/panels/skills-manager.css +975 -0
  7. package/public/css/ui/input-history.css +109 -0
  8. package/public/css/ui/messages.css +51 -0
  9. package/public/css/ui/notification-bell.css +421 -0
  10. package/public/css/ui/sessions.css +41 -0
  11. package/public/css/ui/worktree.css +442 -0
  12. package/public/index.html +43 -10
  13. package/public/js/core/api.js +83 -0
  14. package/public/js/core/dom.js +15 -0
  15. package/public/js/features/background-sessions.js +11 -0
  16. package/public/js/features/chat.js +501 -3
  17. package/public/js/features/input-history.js +122 -0
  18. package/public/js/features/projects.js +16 -1
  19. package/public/js/features/sessions.js +77 -30
  20. package/public/js/main.js +3 -0
  21. package/public/js/panels/git-panel.js +385 -6
  22. package/public/js/panels/skills-manager.js +1005 -0
  23. package/public/js/ui/messages.js +58 -0
  24. package/public/js/ui/notification-bell.js +240 -0
  25. package/public/js/ui/notification-history.js +210 -0
  26. package/public/js/ui/parallel.js +11 -0
  27. package/public/js/ui/tab-sdk.js +1 -1
  28. package/public/style.css +4 -0
  29. package/server/agent-loop.js +13 -0
  30. package/server/notification-logger.js +27 -0
  31. package/server/routes/notifications.js +57 -1
  32. package/server/routes/sessions.js +41 -0
  33. package/server/routes/skills.js +454 -0
  34. package/server/routes/worktrees.js +93 -0
  35. package/server/utils/git-worktree.js +297 -0
  36. package/server/ws-handler.js +708 -629
  37. package/server.js +17 -1
@@ -14,18 +14,23 @@ import {
14
14
  updateSessionTitle,
15
15
  getTopMemories,
16
16
  touchMemory,
17
+ createWorktreeRecord,
18
+ updateWorktreeStatus,
19
+ updateWorktreeSession,
17
20
  } from "../db.js";
18
21
  import { getProjectSystemPrompt } from "./routes/projects.js";
19
22
  import { buildMemoryPrompt, parseRememberCommand, saveExplicitMemories } from "./memory-injector.js";
20
23
  import { captureMemories, runMaintenance } from "./memory-extractor.js";
24
+ import { createWorktree, generateBranchName, getCurrentBranch, autoCommitWorktree, getWorktreeDiffStats } from "./utils/git-worktree.js";
25
+ import { logNotification } from "./notification-logger.js";
21
26
 
22
27
  // Map short model names to current model IDs
23
- const MODEL_MAP = {
28
+ export const MODEL_MAP = {
24
29
  haiku: "claude-haiku-4-5-20251001",
25
30
  sonnet: "claude-sonnet-4-6",
26
31
  opus: "claude-opus-4-6",
27
32
  };
28
- function resolveModel(name) {
33
+ export function resolveModel(name) {
29
34
  if (!name) return undefined;
30
35
  return MODEL_MAP[name] || name;
31
36
  }
@@ -38,14 +43,14 @@ import { runOrchestrator } from "./orchestrator.js";
38
43
  import { runDag } from "./dag-executor.js";
39
44
 
40
45
  // Tools that are read-only and safe to auto-approve in "confirmDangerous" mode
41
- const READ_ONLY_TOOLS = new Set([
46
+ export const READ_ONLY_TOOLS = new Set([
42
47
  "Read", "Glob", "Grep", "WebSearch", "WebFetch", "Agent",
43
48
  "TodoRead", "TaskRead", "NotebookRead", "LS", "View", "ListFiles",
44
49
  "TaskList", "TaskGet",
45
50
  ]);
46
51
 
47
52
  const DEFAULT_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (web)
48
- function getApprovalTimeoutMs() {
53
+ export function getApprovalTimeoutMs() {
49
54
  const cfg = getTelegramConfig();
50
55
  if (telegramEnabled()) {
51
56
  return (cfg.afkTimeoutMinutes || 15) * 60 * 1000;
@@ -57,7 +62,7 @@ function getApprovalTimeoutMs() {
57
62
  // Key: sessionId, Value: Set<queryKey>
58
63
  const globalActiveQueries = new Map();
59
64
 
60
- function registerGlobalQuery(sessionId, queryKey) {
65
+ export function registerGlobalQuery(sessionId, queryKey) {
61
66
  if (!sessionId) return;
62
67
  if (!globalActiveQueries.has(sessionId)) {
63
68
  globalActiveQueries.set(sessionId, new Set());
@@ -65,7 +70,7 @@ function registerGlobalQuery(sessionId, queryKey) {
65
70
  globalActiveQueries.get(sessionId).add(queryKey);
66
71
  }
67
72
 
68
- function unregisterGlobalQuery(sessionId, queryKey) {
73
+ export function unregisterGlobalQuery(sessionId, queryKey) {
69
74
  if (!sessionId) return;
70
75
  const set = globalActiveQueries.get(sessionId);
71
76
  if (set) {
@@ -139,7 +144,7 @@ export function makeCanUseTool(ws, pendingApprovals, permissionMode, chatId, ses
139
144
  }
140
145
 
141
146
  // Shared SDK stream processor — deduplicates chat and workflow message parsing
142
- async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, chatId, cwd, projectName, isWorkflow, stepLabel, workflowId, stepIndex }) {
147
+ export async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, chatId, cwd, projectName, isWorkflow, stepLabel, workflowId, stepIndex }) {
143
148
  let claudeSessionId = null;
144
149
  let resolvedSid = clientSid;
145
150
  let sessionModel = null;
@@ -309,698 +314,772 @@ async function processSdkStream(q, { ws, wsSend, sessionIds, clientSid, chatId,
309
314
  return { claudeSessionId, resolvedSid, lastMetrics };
310
315
  }
311
316
 
312
- export function setupWebSocket(wss, sessionIds) {
313
- wss.on("connection", (ws) => {
314
- const activeQueries = new Map();
315
- const pendingApprovals = new Map();
316
-
317
- // Abort active queries and deny all pending approvals on disconnect
318
- ws.on("close", () => {
319
- // Abort all active SDK streams first (they may be blocked on approval)
320
- for (const [, q] of activeQueries) {
321
- q.abort();
322
- }
323
- activeQueries.clear();
324
-
325
- for (const [id, { resolve, timer }] of pendingApprovals) {
326
- clearTimeout(timer);
327
- resolve({ behavior: "deny", message: "Client disconnected" });
328
- }
329
- pendingApprovals.clear();
330
- });
317
+ // ── Pure function: build prompt with optional images ──────────────────────
318
+ export function buildPrompt(text, imgs) {
319
+ if (!imgs?.length) return text;
320
+ return (async function*() {
321
+ yield {
322
+ type: "user",
323
+ message: { role: "user", content: [
324
+ { type: "text", text },
325
+ ...imgs.map(img => ({
326
+ type: "image",
327
+ source: { type: "base64", media_type: img.mimeType, data: img.data },
328
+ })),
329
+ ]},
330
+ parent_tool_use_id: null,
331
+ session_id: "",
332
+ };
333
+ })();
334
+ }
331
335
 
332
- ws.on("message", async (raw) => {
333
- let msg;
334
- try {
335
- msg = JSON.parse(raw);
336
- } catch {
337
- return;
338
- }
336
+ // ── Extracted handler: close ──────────────────────────────────────────────
337
+ export function handleClose({ activeQueries, pendingApprovals }) {
338
+ // Abort all active SDK streams first (they may be blocked on approval)
339
+ for (const [, q] of activeQueries) {
340
+ q.abort();
341
+ }
342
+ activeQueries.clear();
339
343
 
340
- // Abort handler
341
- if (msg.type === "abort") {
342
- if (msg.chatId) {
343
- const q = activeQueries.get(msg.chatId);
344
- if (q) { q.abort(); activeQueries.delete(msg.chatId); }
345
- } else {
346
- for (const q of activeQueries.values()) q.abort();
347
- activeQueries.clear();
348
- }
349
- // Also deny any pending approvals on abort
350
- for (const [id, { resolve, timer }] of pendingApprovals) {
351
- clearTimeout(timer);
352
- resolve({ behavior: "deny", message: "Aborted by user" });
353
- }
354
- pendingApprovals.clear();
355
- return;
356
- }
344
+ for (const [id, { resolve, timer }] of pendingApprovals) {
345
+ clearTimeout(timer);
346
+ resolve({ behavior: "deny", message: "Client disconnected" });
347
+ }
348
+ pendingApprovals.clear();
349
+ }
357
350
 
358
- // Permission response handler (from web UI)
359
- if (msg.type === "permission_response") {
360
- const pending = pendingApprovals.get(msg.id);
361
- if (pending) {
362
- clearTimeout(pending.timer);
363
- pendingApprovals.delete(msg.id);
364
- if (msg.behavior === "allow") {
365
- pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
366
- } else {
367
- pending.resolve({ behavior: "deny", message: "Denied by user" });
368
- }
369
- // Update Telegram message to show it was resolved via web
370
- markTelegramMessageResolved(msg.id, msg.behavior === "allow" ? "allow" : "deny").catch(() => {});
371
- }
372
- return;
373
- }
351
+ // ── Extracted handler: abort ──────────────────────────────────────────────
352
+ export function handleAbort(msg, { activeQueries, pendingApprovals }) {
353
+ if (msg.chatId) {
354
+ const q = activeQueries.get(msg.chatId);
355
+ if (q) { q.abort(); activeQueries.delete(msg.chatId); }
356
+ } else {
357
+ for (const q of activeQueries.values()) q.abort();
358
+ activeQueries.clear();
359
+ }
360
+ // Also deny any pending approvals on abort
361
+ for (const [id, { resolve, timer }] of pendingApprovals) {
362
+ clearTimeout(timer);
363
+ resolve({ behavior: "deny", message: "Aborted by user" });
364
+ }
365
+ pendingApprovals.clear();
366
+ }
374
367
 
375
- // Workflow handler
376
- if (msg.type === "workflow") {
377
- const { workflow, cwd, sessionId: clientSid, projectName, permissionMode: wfPermMode, model: wfModel } = msg;
378
- if (!workflow || !workflow.steps) return;
368
+ // ── Extracted handler: permission response ────────────────────────────────
369
+ export function handlePermissionResponse(msg, { pendingApprovals }) {
370
+ const pending = pendingApprovals.get(msg.id);
371
+ if (pending) {
372
+ clearTimeout(pending.timer);
373
+ pendingApprovals.delete(msg.id);
374
+ if (msg.behavior === "allow") {
375
+ pending.resolve({ behavior: "allow", updatedInput: pending.toolInput });
376
+ } else {
377
+ pending.resolve({ behavior: "deny", message: "Denied by user" });
378
+ }
379
+ // Update Telegram message to show it was resolved via web
380
+ markTelegramMessageResolved(msg.id, msg.behavior === "allow" ? "allow" : "deny").catch(() => {});
381
+ }
382
+ }
379
383
 
380
- function wfSend(payload) {
381
- if (ws.readyState !== 1) return;
382
- ws.send(JSON.stringify(payload));
383
- }
384
+ // ── Extracted handler: workflow ────────────────────────────────────────────
385
+ export async function handleWorkflow(msg, { ws, sessionIds, activeQueries, pendingApprovals }) {
386
+ const { workflow, cwd, sessionId: clientSid, projectName, permissionMode: wfPermMode, model: wfModel } = msg;
387
+ if (!workflow || !workflow.steps) return;
384
388
 
385
- wfSend({ type: "workflow_started", workflow: { id: workflow.id, title: workflow.title, steps: workflow.steps.map((s) => s.label) } });
389
+ function wfSend(payload) {
390
+ if (ws.readyState !== 1) return;
391
+ ws.send(JSON.stringify(payload));
392
+ }
386
393
 
387
- // Telegram start notification
388
- const wfStepNames = workflow.steps.map((s, i) => ` ${i + 1}. ${s.label}`).join("\n");
389
- sendTelegramNotification("start", "Workflow Started", `${workflow.title}\n\n${workflow.steps.length} steps:\n${wfStepNames}`);
394
+ wfSend({ type: "workflow_started", workflow: { id: workflow.id, title: workflow.title, steps: workflow.steps.map((s) => s.label) } });
390
395
 
391
- let resumeId = clientSid ? sessionIds.get(clientSid) : undefined;
392
- let resolvedSid = clientSid;
393
- const wfQueryKey = `wf-${workflow.id}-${Date.now()}`;
394
- let wfAborted = false;
396
+ // Telegram start notification
397
+ const wfStepNames = workflow.steps.map((s, i) => ` ${i + 1}. ${s.label}`).join("\n");
398
+ sendTelegramNotification("start", "Workflow Started", `${workflow.title}\n\n${workflow.steps.length} steps:\n${wfStepNames}`);
395
399
 
396
- for (let i = 0; i < workflow.steps.length; i++) {
397
- if (wfAborted || ws.readyState !== 1) break;
400
+ let resumeId = clientSid ? sessionIds.get(clientSid) : undefined;
401
+ let resolvedSid = clientSid;
402
+ const wfQueryKey = `wf-${workflow.id}-${Date.now()}`;
403
+ let wfAborted = false;
404
+
405
+ for (let i = 0; i < workflow.steps.length; i++) {
406
+ if (wfAborted || ws.readyState !== 1) break;
407
+
408
+ const step = workflow.steps[i];
409
+ wfSend({ type: "workflow_step", stepIndex: i, status: "running" });
410
+
411
+ const abortController = new AbortController();
412
+ activeQueries.set(wfQueryKey, { abort: () => abortController.abort() });
413
+
414
+ const effectivePermMode = wfPermMode || "bypass";
415
+ const useBypass = effectivePermMode === "bypass";
416
+ const usePlan = effectivePermMode === "plan";
417
+ const wfCwd = (cwd && existsSync(cwd)) ? cwd : homedir();
418
+ const stepOpts = {
419
+ cwd: wfCwd,
420
+ permissionMode: usePlan ? "plan" : (useBypass ? "bypassPermissions" : "default"),
421
+ abortController,
422
+ maxTurns: 30,
423
+ executable: execPath,
424
+ };
425
+
426
+ if (!useBypass && !usePlan) {
427
+ stepOpts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, null, `Workflow: ${workflow.title}`);
428
+ }
429
+ if (wfModel) stepOpts.model = resolveModel(wfModel);
430
+
431
+ const projectPrompt = getProjectSystemPrompt(cwd);
432
+ if (projectPrompt) stepOpts.appendSystemPrompt = projectPrompt;
433
+ if (resumeId) stepOpts.resume = resumeId;
434
+
435
+ try {
436
+ const q = query({ prompt: step.prompt, options: stepOpts });
437
+ const result = await processSdkStream(q, {
438
+ ws, wsSend: wfSend, sessionIds,
439
+ clientSid: resolvedSid, chatId: null,
440
+ cwd, projectName: projectName || "Workflow",
441
+ isWorkflow: true, stepLabel: step.label,
442
+ workflowId: workflow.id, stepIndex: i,
443
+ });
444
+
445
+ if (result.resolvedSid) resolvedSid = result.resolvedSid;
446
+ if (result.claudeSessionId) resumeId = result.claudeSessionId;
447
+
448
+ if (i === 0 && result.resolvedSid && !clientSid) {
449
+ wfSend({ type: "session", sessionId: result.resolvedSid });
450
+ }
451
+ } catch (err) {
452
+ if (err.name === "AbortError" || abortController.signal.aborted) {
453
+ wfAborted = true;
454
+ wfSend({ type: "workflow_step", stepIndex: i, status: "aborted" });
455
+ break;
456
+ }
457
+ wfSend({ type: "error", error: `Workflow step "${step.label}" failed: ${err.message}` });
458
+ sendTelegramNotification("error", "Workflow Step Failed", `${workflow.title}\n\nStep ${i + 1}/${workflow.steps.length}: ${step.label}\nError: ${err.message}`);
459
+ break;
460
+ }
398
461
 
399
- const step = workflow.steps[i];
400
- wfSend({ type: "workflow_step", stepIndex: i, status: "running" });
462
+ wfSend({ type: "workflow_step", stepIndex: i, status: "completed" });
463
+ }
401
464
 
402
- const abortController = new AbortController();
403
- activeQueries.set(wfQueryKey, { abort: () => abortController.abort() });
465
+ activeQueries.delete(wfQueryKey);
466
+
467
+ if (wfAborted) {
468
+ wfSend({ type: "workflow_completed", aborted: true });
469
+ wfSend({ type: "done" });
470
+ sendTelegramNotification("error", "Workflow Aborted", `${workflow.title}\nAborted during execution`);
471
+ } else {
472
+ wfSend({ type: "workflow_completed" });
473
+ wfSend({ type: "done" });
474
+ sendPushNotification("Claudeck", `Workflow "${workflow.title}" completed`, `wf-${resolvedSid}`);
475
+ const stepNames = workflow.steps.map((s, i) => ` ${i + 1}. ${s.label}`).join("\n");
476
+ sendTelegramNotification("workflow", "Workflow Completed", `${workflow.title}\n\nSteps:\n${stepNames}`, {
477
+ steps: workflow.steps.length,
478
+ });
479
+ }
480
+ }
404
481
 
405
- const effectivePermMode = wfPermMode || "bypass";
406
- const useBypass = effectivePermMode === "bypass";
407
- const usePlan = effectivePermMode === "plan";
408
- const wfCwd = (cwd && existsSync(cwd)) ? cwd : homedir();
409
- const stepOpts = {
410
- cwd: wfCwd,
411
- permissionMode: usePlan ? "plan" : (useBypass ? "bypassPermissions" : "default"),
412
- abortController,
413
- maxTurns: 30,
414
- executable: execPath,
415
- };
482
+ // ── Extracted handler: agent ──────────────────────────────────────────────
483
+ export async function handleAgent(msg, { ws, sessionIds, activeQueries, pendingApprovals }) {
484
+ const { agentDef, cwd, sessionId: clientSid, projectName, permissionMode: agentPermMode, model: agentModel, userContext } = msg;
485
+ if (!agentDef) return;
486
+
487
+ runAgent({
488
+ ws,
489
+ agentDef,
490
+ cwd,
491
+ sessionId: clientSid,
492
+ projectName,
493
+ permissionMode: agentPermMode,
494
+ model: agentModel,
495
+ sessionIds,
496
+ pendingApprovals,
497
+ makeCanUseTool,
498
+ userContext,
499
+ activeQueries,
500
+ runType: 'single',
501
+ }).catch(() => {}); // errors already handled inside runAgent
502
+ }
416
503
 
417
- if (!useBypass && !usePlan) {
418
- stepOpts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, null, `Workflow: ${workflow.title}`);
419
- }
420
- if (wfModel) stepOpts.model = resolveModel(wfModel);
421
-
422
- const projectPrompt = getProjectSystemPrompt(cwd);
423
- if (projectPrompt) stepOpts.appendSystemPrompt = projectPrompt;
424
- if (resumeId) stepOpts.resume = resumeId;
425
-
426
- try {
427
- const q = query({ prompt: step.prompt, options: stepOpts });
428
- const result = await processSdkStream(q, {
429
- ws, wsSend: wfSend, sessionIds,
430
- clientSid: resolvedSid, chatId: null,
431
- cwd, projectName: projectName || "Workflow",
432
- isWorkflow: true, stepLabel: step.label,
433
- workflowId: workflow.id, stepIndex: i,
434
- });
435
-
436
- if (result.resolvedSid) resolvedSid = result.resolvedSid;
437
- if (result.claudeSessionId) resumeId = result.claudeSessionId;
438
-
439
- if (i === 0 && result.resolvedSid && !clientSid) {
440
- wfSend({ type: "session", sessionId: result.resolvedSid });
441
- }
442
- } catch (err) {
443
- if (err.name === "AbortError" || abortController.signal.aborted) {
444
- wfAborted = true;
445
- wfSend({ type: "workflow_step", stepIndex: i, status: "aborted" });
446
- break;
447
- }
448
- wfSend({ type: "error", error: `Workflow step "${step.label}" failed: ${err.message}` });
449
- sendTelegramNotification("error", "Workflow Step Failed", `${workflow.title}\n\nStep ${i + 1}/${workflow.steps.length}: ${step.label}\nError: ${err.message}`);
450
- break;
451
- }
504
+ // ── Extracted handler: agent chain ────────────────────────────────────────
505
+ export async function handleAgentChain(msg, { ws, sessionIds, activeQueries, pendingApprovals }) {
506
+ const { chain, agents: agentDefs, cwd, sessionId: clientSid, projectName, permissionMode: chainPermMode, model: chainModel } = msg;
507
+ if (!chain || !agentDefs?.length) return;
452
508
 
453
- wfSend({ type: "workflow_step", stepIndex: i, status: "completed" });
454
- }
509
+ const runId = crypto.randomUUID();
455
510
 
456
- activeQueries.delete(wfQueryKey);
511
+ function chainSend(payload) {
512
+ if (ws.readyState !== 1) return;
513
+ ws.send(JSON.stringify(payload));
514
+ }
457
515
 
458
- if (wfAborted) {
459
- wfSend({ type: "workflow_completed", aborted: true });
460
- wfSend({ type: "done" });
461
- sendTelegramNotification("error", "Workflow Aborted", `${workflow.title}\nAborted during execution`);
462
- } else {
463
- wfSend({ type: "workflow_completed" });
464
- wfSend({ type: "done" });
465
- sendPushNotification("Claudeck", `Workflow "${workflow.title}" completed`, `wf-${resolvedSid}`);
466
- const stepNames = workflow.steps.map((s, i) => ` ${i + 1}. ${s.label}`).join("\n");
467
- sendTelegramNotification("workflow", "Workflow Completed", `${workflow.title}\n\nSteps:\n${stepNames}`, {
468
- steps: workflow.steps.length,
469
- });
470
- }
471
- return;
472
- }
516
+ chainSend({
517
+ type: "agent_chain_started",
518
+ chainId: chain.id,
519
+ runId,
520
+ title: chain.title,
521
+ agents: agentDefs.map(a => ({ id: a.id, title: a.title })),
522
+ totalSteps: agentDefs.length,
523
+ });
473
524
 
474
- // Agent handler
475
- if (msg.type === "agent") {
476
- const { agentDef, cwd, sessionId: clientSid, projectName, permissionMode: agentPermMode, model: agentModel, userContext } = msg;
477
- if (!agentDef) return;
478
-
479
- runAgent({
480
- ws,
481
- agentDef,
482
- cwd,
483
- sessionId: clientSid,
484
- projectName,
485
- permissionMode: agentPermMode,
486
- model: agentModel,
487
- sessionIds,
488
- pendingApprovals,
489
- makeCanUseTool,
490
- userContext,
491
- activeQueries,
492
- runType: 'single',
493
- }).catch(() => {}); // errors already handled inside runAgent
494
- return;
495
- }
525
+ // Telegram start notification
526
+ const chainAgentNames = agentDefs.map((a, i) => ` ${i + 1}. ${a.title}`).join("\n");
527
+ sendTelegramNotification("start", "Chain Started", `${chain.title}\n\n${agentDefs.length} agents:\n${chainAgentNames}`);
496
528
 
497
- // Agent chain handler sequential multi-agent execution with context passing
498
- if (msg.type === "agent_chain") {
499
- const { chain, agents: agentDefs, cwd, sessionId: clientSid, projectName, permissionMode: chainPermMode, model: chainModel } = msg;
500
- if (!chain || !agentDefs?.length) return;
529
+ let chainResumeId = clientSid ? sessionIds.get(clientSid) : undefined;
530
+ let resolvedSid = clientSid;
501
531
 
502
- const runId = crypto.randomUUID();
532
+ for (let i = 0; i < agentDefs.length; i++) {
533
+ const agentDef = agentDefs[i];
534
+ if (ws.readyState !== 1) break;
503
535
 
504
- function chainSend(payload) {
505
- if (ws.readyState !== 1) return;
506
- ws.send(JSON.stringify(payload));
507
- }
536
+ chainSend({
537
+ type: "agent_chain_step",
538
+ chainId: chain.id,
539
+ stepIndex: i,
540
+ agentId: agentDef.id,
541
+ agentTitle: agentDef.title,
542
+ status: "running",
543
+ });
508
544
 
509
- chainSend({
510
- type: "agent_chain_started",
511
- chainId: chain.id,
512
- runId,
513
- title: chain.title,
514
- agents: agentDefs.map(a => ({ id: a.id, title: a.title })),
515
- totalSteps: agentDefs.length,
516
- });
545
+ try {
546
+ const result = await runAgent({
547
+ ws,
548
+ agentDef,
549
+ cwd,
550
+ sessionId: resolvedSid,
551
+ projectName: projectName || `Chain: ${chain.title}`,
552
+ permissionMode: chainPermMode,
553
+ model: chainModel,
554
+ sessionIds,
555
+ pendingApprovals,
556
+ makeCanUseTool,
557
+ activeQueries,
558
+ chainResumeId,
559
+ runId,
560
+ runType: 'chain',
561
+ parentRunId: chain.id,
562
+ });
563
+
564
+ if (result?.resolvedSid) resolvedSid = result.resolvedSid;
565
+ if (result?.claudeSessionId) chainResumeId = result.claudeSessionId;
566
+
567
+ chainSend({
568
+ type: "agent_chain_step",
569
+ chainId: chain.id,
570
+ stepIndex: i,
571
+ agentId: agentDef.id,
572
+ agentTitle: agentDef.title,
573
+ status: "completed",
574
+ });
575
+ } catch (err) {
576
+ chainSend({
577
+ type: "agent_chain_step",
578
+ chainId: chain.id,
579
+ stepIndex: i,
580
+ agentId: agentDef.id,
581
+ agentTitle: agentDef.title,
582
+ status: "error",
583
+ error: err.message,
584
+ });
585
+ sendTelegramNotification("error", "Chain Agent Failed", `${chain.title}\n\nAgent ${i + 1}/${agentDefs.length}: ${agentDef.title}\nError: ${err.message}`);
586
+ break;
587
+ }
588
+ }
517
589
 
518
- // Telegram start notification
519
- const chainAgentNames = agentDefs.map((a, i) => ` ${i + 1}. ${a.title}`).join("\n");
520
- sendTelegramNotification("start", "Chain Started", `${chain.title}\n\n${agentDefs.length} agents:\n${chainAgentNames}`);
521
-
522
- let chainResumeId = clientSid ? sessionIds.get(clientSid) : undefined;
523
- let resolvedSid = clientSid;
524
-
525
- for (let i = 0; i < agentDefs.length; i++) {
526
- const agentDef = agentDefs[i];
527
- if (ws.readyState !== 1) break;
528
-
529
- chainSend({
530
- type: "agent_chain_step",
531
- chainId: chain.id,
532
- stepIndex: i,
533
- agentId: agentDef.id,
534
- agentTitle: agentDef.title,
535
- status: "running",
536
- });
537
-
538
- try {
539
- const result = await runAgent({
540
- ws,
541
- agentDef,
542
- cwd,
543
- sessionId: resolvedSid,
544
- projectName: projectName || `Chain: ${chain.title}`,
545
- permissionMode: chainPermMode,
546
- model: chainModel,
547
- sessionIds,
548
- pendingApprovals,
549
- makeCanUseTool,
550
- activeQueries,
551
- chainResumeId,
552
- runId,
553
- runType: 'chain',
554
- parentRunId: chain.id,
555
- });
556
-
557
- if (result?.resolvedSid) resolvedSid = result.resolvedSid;
558
- if (result?.claudeSessionId) chainResumeId = result.claudeSessionId;
559
-
560
- chainSend({
561
- type: "agent_chain_step",
562
- chainId: chain.id,
563
- stepIndex: i,
564
- agentId: agentDef.id,
565
- agentTitle: agentDef.title,
566
- status: "completed",
567
- });
568
- } catch (err) {
569
- chainSend({
570
- type: "agent_chain_step",
571
- chainId: chain.id,
572
- stepIndex: i,
573
- agentId: agentDef.id,
574
- agentTitle: agentDef.title,
575
- status: "error",
576
- error: err.message,
577
- });
578
- sendTelegramNotification("error", "Chain Agent Failed", `${chain.title}\n\nAgent ${i + 1}/${agentDefs.length}: ${agentDef.title}\nError: ${err.message}`);
579
- break;
580
- }
581
- }
590
+ chainSend({ type: "agent_chain_completed", chainId: chain.id, runId });
591
+ sendPushNotification("Claudeck", `Chain "${chain.title}" completed`, `chain-${resolvedSid}`);
592
+ const agentNames = agentDefs.map((a, i) => ` ${i + 1}. ${a.title}`).join("\n");
593
+ sendTelegramNotification("chain", "Chain Completed", `${chain.title}\n\nAgents:\n${agentNames}`, {
594
+ steps: agentDefs.length,
595
+ });
596
+ }
582
597
 
583
- chainSend({ type: "agent_chain_completed", chainId: chain.id, runId });
584
- sendPushNotification("Claudeck", `Chain "${chain.title}" completed`, `chain-${resolvedSid}`);
585
- const agentNames = agentDefs.map((a, i) => ` ${i + 1}. ${a.title}`).join("\n");
586
- sendTelegramNotification("chain", "Chain Completed", `${chain.title}\n\nAgents:\n${agentNames}`, {
587
- steps: agentDefs.length,
588
- });
589
- return;
590
- }
598
+ // ── Extracted handler: DAG ────────────────────────────────────────────────
599
+ export async function handleDag(msg, { ws, sessionIds, activeQueries, pendingApprovals }) {
600
+ const { dag, agents: agentDefs, cwd, sessionId: clientSid, projectName, permissionMode: dagPermMode, model: dagModel } = msg;
601
+ if (!dag || !agentDefs?.length) return;
602
+
603
+ runDag({
604
+ ws,
605
+ dag,
606
+ agents: agentDefs,
607
+ cwd,
608
+ sessionId: clientSid,
609
+ projectName,
610
+ permissionMode: dagPermMode,
611
+ model: dagModel,
612
+ sessionIds,
613
+ pendingApprovals,
614
+ makeCanUseTool,
615
+ activeQueries,
616
+ });
617
+ }
591
618
 
592
- // DAG handler runs agents in dependency order with parallelism
593
- if (msg.type === "agent_dag") {
594
- const { dag, agents: agentDefs, cwd, sessionId: clientSid, projectName, permissionMode: dagPermMode, model: dagModel } = msg;
595
- if (!dag || !agentDefs?.length) return;
596
-
597
- runDag({
598
- ws,
599
- dag,
600
- agents: agentDefs,
601
- cwd,
602
- sessionId: clientSid,
603
- projectName,
604
- permissionMode: dagPermMode,
605
- model: dagModel,
606
- sessionIds,
607
- pendingApprovals,
608
- makeCanUseTool,
609
- activeQueries,
610
- });
611
- return;
612
- }
619
+ // ── Extracted handler: orchestrate ────────────────────────────────────────
620
+ export async function handleOrchestrate(msg, { ws, sessionIds, activeQueries, pendingApprovals }) {
621
+ const { task, cwd, sessionId: clientSid, projectName, permissionMode: orchPermMode, model: orchModel } = msg;
622
+ if (!task) return;
623
+
624
+ const { readFile } = await import("fs/promises");
625
+ const { configPath } = await import("./paths.js");
626
+ let agents;
627
+ try {
628
+ agents = JSON.parse(await readFile(configPath("agents.json"), "utf-8"));
629
+ } catch {
630
+ ws.send(JSON.stringify({ type: "error", error: "Failed to load agents" }));
631
+ return;
632
+ }
613
633
 
614
- // Orchestrator handler — meta-agent that decomposes tasks and delegates
615
- if (msg.type === "orchestrate") {
616
- const { task, cwd, sessionId: clientSid, projectName, permissionMode: orchPermMode, model: orchModel } = msg;
617
- if (!task) return;
634
+ runOrchestrator({
635
+ ws,
636
+ task,
637
+ agents,
638
+ cwd,
639
+ sessionId: clientSid,
640
+ projectName,
641
+ permissionMode: orchPermMode,
642
+ model: orchModel,
643
+ sessionIds,
644
+ pendingApprovals,
645
+ makeCanUseTool,
646
+ activeQueries,
647
+ });
648
+ }
618
649
 
619
- const { readFile } = await import("fs/promises");
620
- const { configPath } = await import("./paths.js");
621
- let agents;
622
- try {
623
- agents = JSON.parse(await readFile(configPath("agents.json"), "utf-8"));
624
- } catch {
625
- ws.send(JSON.stringify({ type: "error", error: "Failed to load agents" }));
626
- return;
627
- }
650
+ // ── Extracted handler: chat ───────────────────────────────────────────────
651
+ export async function handleChat(msg, { ws, sessionIds, activeQueries, pendingApprovals }) {
652
+ const { message, cwd, sessionId: clientSid, projectName, chatId, permissionMode: clientPermMode, model: chatModel, maxTurns: clientMaxTurns, images, systemPrompt, disabledTools, worktree } = msg;
653
+
654
+ // Handle /remember command — save memory and respond without calling Claude
655
+ if (message && message.trim().toLowerCase().startsWith('/remember ') && cwd) {
656
+ const result = parseRememberCommand(message, cwd, clientSid);
657
+ function remSend(payload) {
658
+ if (ws.readyState !== 1) return;
659
+ if (chatId) payload.chatId = chatId;
660
+ ws.send(JSON.stringify(payload));
661
+ }
662
+ if (result) {
663
+ remSend({ type: "text", text: result.saved
664
+ ? `Saved memory [${result.category}]: ${result.content}`
665
+ : `Memory already exists: ${result.content}` });
666
+ remSend({ type: "memory_saved", category: result.category, content: result.content, isDuplicate: !result.saved });
667
+ } else {
668
+ remSend({ type: "text", text: "Usage: /remember [category] what to remember\nCategories: convention, decision, discovery, warning" });
669
+ }
670
+ remSend({ type: "done" });
671
+ return;
672
+ }
628
673
 
629
- runOrchestrator({
630
- ws,
631
- task,
632
- agents,
633
- cwd,
634
- sessionId: clientSid,
635
- projectName,
636
- permissionMode: orchPermMode,
637
- model: orchModel,
638
- sessionIds,
639
- pendingApprovals,
640
- makeCanUseTool,
641
- activeQueries,
642
- });
643
- return;
644
- }
674
+ const queryKey = chatId || "__default__";
645
675
 
646
- // Chat handler
647
- if (msg.type !== "chat") return;
676
+ const sessionKey = chatId ? `${clientSid}::${chatId}` : clientSid;
677
+ const resumeId = clientSid ? sessionIds.get(sessionKey) : undefined;
648
678
 
649
- const { message, cwd, sessionId: clientSid, projectName, chatId, permissionMode: clientPermMode, model: chatModel, maxTurns: clientMaxTurns, images, systemPrompt, disabledTools } = msg;
679
+ if (clientSid && getSession(clientSid)) {
680
+ touchSession(clientSid);
681
+ }
650
682
 
651
- // Handle /remember command — save memory and respond without calling Claude
652
- if (message && message.trim().toLowerCase().startsWith('/remember ') && cwd) {
653
- const result = parseRememberCommand(message, cwd, clientSid);
654
- function remSend(payload) {
655
- if (ws.readyState !== 1) return;
656
- if (chatId) payload.chatId = chatId;
657
- ws.send(JSON.stringify(payload));
658
- }
659
- if (result) {
660
- remSend({ type: "text", text: result.saved
661
- ? `Saved memory [${result.category}]: ${result.content}`
662
- : `Memory already exists: ${result.content}` });
663
- remSend({ type: "memory_saved", category: result.category, content: result.content, isDuplicate: !result.saved });
664
- } else {
665
- remSend({ type: "text", text: "Usage: /remember [category] what to remember\nCategories: convention, decision, discovery, warning" });
666
- }
667
- remSend({ type: "done" });
668
- return;
683
+ const abortController = new AbortController();
684
+ const effectivePermMode = clientPermMode || "bypass";
685
+ const useBypass = effectivePermMode === "bypass";
686
+ const usePlan = effectivePermMode === "plan";
687
+ const resolvedCwd = (cwd && existsSync(cwd)) ? cwd : homedir();
688
+
689
+ // ── Worktree mode: create isolated worktree before SDK query ──
690
+ let worktreeRecord = null;
691
+ let effectiveCwd = resolvedCwd;
692
+
693
+ console.log(`[worktree] flag=${worktree}, cwd=${cwd}, exists=${cwd ? existsSync(cwd) : 'n/a'}`);
694
+
695
+ if (worktree && cwd && existsSync(cwd)) {
696
+ try {
697
+ const branchName = generateBranchName(message);
698
+ console.log(`[worktree] Creating worktree: branch=${branchName}, project=${cwd}`);
699
+ const baseBranch = await getCurrentBranch(cwd);
700
+ const wtResult = await createWorktree(cwd, branchName);
701
+ const wtId = crypto.randomUUID();
702
+
703
+ createWorktreeRecord(wtId, clientSid || null, cwd, wtResult.worktreePath, branchName, baseBranch, (message || "").slice(0, 200));
704
+ worktreeRecord = { id: wtId, worktreePath: wtResult.worktreePath, branchName, baseBranch };
705
+ effectiveCwd = wtResult.worktreePath;
706
+
707
+ // Notify client of worktree creation
708
+ if (ws.readyState === 1) {
709
+ const wtPayload = { type: "worktree_created", worktreeId: wtId, branchName, baseBranch, worktreePath: wtResult.worktreePath };
710
+ if (chatId) wtPayload.chatId = chatId;
711
+ ws.send(JSON.stringify(wtPayload));
669
712
  }
670
-
671
- const queryKey = chatId || "__default__";
672
-
673
- const sessionKey = chatId ? `${clientSid}::${chatId}` : clientSid;
674
- const resumeId = clientSid ? sessionIds.get(sessionKey) : undefined;
675
-
676
- if (clientSid && getSession(clientSid)) {
677
- touchSession(clientSid);
713
+ } catch (err) {
714
+ console.error("Worktree creation failed:", err.message, err.stack);
715
+ // Notify client of failure so they know it fell back to normal mode
716
+ if (ws.readyState === 1) {
717
+ const errPayload = { type: "worktree_error", error: err.message };
718
+ if (chatId) errPayload.chatId = chatId;
719
+ ws.send(JSON.stringify(errPayload));
678
720
  }
721
+ // Fall back to normal cwd — don't block the query
722
+ }
723
+ }
679
724
 
680
- const abortController = new AbortController();
681
- const effectivePermMode = clientPermMode || "bypass";
682
- const useBypass = effectivePermMode === "bypass";
683
- const usePlan = effectivePermMode === "plan";
684
- const resolvedCwd = (cwd && existsSync(cwd)) ? cwd : homedir();
685
- const stderrChunks = [];
686
- const effectiveMaxTurns = clientMaxTurns > 0 ? clientMaxTurns : undefined;
687
- const opts = {
688
- cwd: resolvedCwd,
689
- permissionMode: usePlan ? "plan" : (useBypass ? "bypassPermissions" : "default"),
690
- abortController,
691
- executable: execPath,
692
- stderr: (text) => stderrChunks.push(text),
693
- };
694
- if (effectiveMaxTurns) opts.maxTurns = effectiveMaxTurns;
695
-
696
- if (!useBypass && !usePlan) {
697
- opts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, chatId, projectName || "Chat");
698
- }
699
- if (chatModel) opts.model = resolveModel(chatModel);
700
- if (Array.isArray(disabledTools) && disabledTools.length > 0) {
701
- opts.disallowedTools = disabledTools;
702
- }
725
+ const stderrChunks = [];
726
+ const effectiveMaxTurns = clientMaxTurns > 0 ? clientMaxTurns : undefined;
727
+ const opts = {
728
+ cwd: effectiveCwd,
729
+ permissionMode: usePlan ? "plan" : (useBypass ? "bypassPermissions" : "default"),
730
+ abortController,
731
+ executable: execPath,
732
+ stderr: (text) => stderrChunks.push(text),
733
+ };
734
+ if (effectiveMaxTurns) opts.maxTurns = effectiveMaxTurns;
703
735
 
704
- const projectPrompt = getProjectSystemPrompt(cwd);
705
- if (projectPrompt) opts.appendSystemPrompt = projectPrompt;
706
- if (systemPrompt) {
707
- opts.appendSystemPrompt = (opts.appendSystemPrompt || '') +
708
- (opts.appendSystemPrompt ? '\n\n' : '') + systemPrompt;
709
- }
710
- // Run memory maintenance (decay stale, clean expired) on each session
711
- if (cwd) runMaintenance(cwd);
712
- // Inject persistent memories for this project (smart: uses user message for relevance)
713
- if (cwd) {
714
- const { prompt: memPrompt, count: memCount, memories: memList } = buildMemoryPrompt(cwd, 10, message);
715
- if (memPrompt) {
716
- opts.appendSystemPrompt = (opts.appendSystemPrompt || '') +
717
- (opts.appendSystemPrompt ? '\n\n' : '') + memPrompt;
718
-
719
- // Log memories being passed to Claude SDK
720
- console.log(`\n══════ MEMORY INJECTION ══════`);
721
- console.log(`Project: ${cwd}`);
722
- console.log(`User message: "${(message || '').slice(0, 100)}"`);
723
- console.log(`Memories injected: ${memCount}`);
724
- for (const m of memList) {
725
- console.log(` [${m.category}] ${m.content.slice(0, 120)}`);
726
- }
727
- console.log(`Prompt appended to appendSystemPrompt (${memPrompt.length} chars)`);
728
- console.log(`══════════════════════════════\n`);
736
+ if (!useBypass && !usePlan) {
737
+ opts.canUseTool = makeCanUseTool(ws, pendingApprovals, effectivePermMode, chatId, projectName || "Chat");
738
+ }
739
+ if (chatModel) opts.model = resolveModel(chatModel);
740
+ if (Array.isArray(disabledTools) && disabledTools.length > 0) {
741
+ opts.disallowedTools = disabledTools;
742
+ }
743
+ // Prevent SDK from creating nested worktrees when Claudeck manages them
744
+ if (worktreeRecord) {
745
+ opts.disallowedTools = [...(opts.disallowedTools || []), "EnterWorktree"];
746
+ }
729
747
 
730
- // Notify client that memories were injected (include content for display)
731
- if (ws.readyState === 1) {
732
- const payload = { type: "memories_injected", count: memCount, memories: memList };
733
- if (chatId) payload.chatId = chatId;
734
- ws.send(JSON.stringify(payload));
735
- }
736
- } else {
737
- console.log(`\n══════ MEMORY INJECTION ══════`);
738
- console.log(`Project: ${cwd}`);
739
- console.log(`No memories found for this project`);
740
- console.log(`══════════════════════════════\n`);
741
- }
748
+ const projectPrompt = getProjectSystemPrompt(cwd);
749
+ if (projectPrompt) opts.appendSystemPrompt = projectPrompt;
750
+ if (systemPrompt) {
751
+ opts.appendSystemPrompt = (opts.appendSystemPrompt || '') +
752
+ (opts.appendSystemPrompt ? '\n\n' : '') + systemPrompt;
753
+ }
754
+ // Run memory maintenance (decay stale, clean expired) on each session
755
+ if (cwd) runMaintenance(cwd);
756
+ // Inject persistent memories for this project (smart: uses user message for relevance)
757
+ if (cwd) {
758
+ const { prompt: memPrompt, count: memCount, memories: memList } = buildMemoryPrompt(cwd, 10, message);
759
+ if (memPrompt) {
760
+ opts.appendSystemPrompt = (opts.appendSystemPrompt || '') +
761
+ (opts.appendSystemPrompt ? '\n\n' : '') + memPrompt;
762
+
763
+ // Log memories being passed to Claude SDK
764
+ console.log(`\n══════ MEMORY INJECTION ══════`);
765
+ console.log(`Project: ${cwd}`);
766
+ console.log(`User message: "${(message || '').slice(0, 100)}"`);
767
+ console.log(`Memories injected: ${memCount}`);
768
+ for (const m of memList) {
769
+ console.log(` [${m.category}] ${m.content.slice(0, 120)}`);
742
770
  }
743
- if (resumeId) opts.resume = resumeId;
744
-
745
- let resolvedSid = clientSid;
771
+ console.log(`Prompt appended to appendSystemPrompt (${memPrompt.length} chars)`);
772
+ console.log(`══════════════════════════════\n`);
746
773
 
747
- function wsSend(payload) {
748
- if (ws.readyState !== 1) return;
774
+ // Notify client that memories were injected (include content for display)
775
+ if (ws.readyState === 1) {
776
+ const payload = { type: "memories_injected", count: memCount, memories: memList };
749
777
  if (chatId) payload.chatId = chatId;
750
- if (resolvedSid) payload.sessionId = resolvedSid;
751
778
  ws.send(JSON.stringify(payload));
752
779
  }
780
+ } else {
781
+ console.log(`\n══════ MEMORY INJECTION ══════`);
782
+ console.log(`Project: ${cwd}`);
783
+ console.log(`No memories found for this project`);
784
+ console.log(`══════════════════════════════\n`);
785
+ }
786
+ }
787
+ if (resumeId) opts.resume = resumeId;
753
788
 
754
- // Register for global tracking if we already know the session
755
- if (clientSid) registerGlobalQuery(clientSid, queryKey);
756
-
757
- function buildPrompt(text, imgs) {
758
- if (!imgs?.length) return text;
759
- return (async function*() {
760
- yield {
761
- type: "user",
762
- message: { role: "user", content: [
763
- { type: "text", text },
764
- ...imgs.map(img => ({
765
- type: "image",
766
- source: { type: "base64", media_type: img.mimeType, data: img.data },
767
- })),
768
- ]},
769
- parent_tool_use_id: null,
770
- session_id: "",
771
- };
772
- })();
773
- }
789
+ const state = { resolvedSid: clientSid, lastAssistantText: "", lastChatMetrics: {} };
774
790
 
775
- let lastChatMetrics = {};
776
- let lastAssistantText = "";
791
+ function wsSend(payload) {
792
+ if (ws.readyState !== 1) return;
793
+ if (chatId) payload.chatId = chatId;
794
+ if (state.resolvedSid) payload.sessionId = state.resolvedSid;
795
+ ws.send(JSON.stringify(payload));
796
+ }
777
797
 
778
- async function runQuery(queryOpts) {
779
- const q = query({ prompt: buildPrompt(message, images), options: queryOpts });
780
- activeQueries.set(queryKey, { abort: () => abortController.abort() });
798
+ // Register for global tracking if we already know the session
799
+ if (clientSid) registerGlobalQuery(clientSid, queryKey);
781
800
 
782
- let claudeSessionId = null;
783
- let sessionModel = null;
801
+ async function runChatQuery(queryOpts) {
802
+ const q = query({ prompt: buildPrompt(message, images), options: queryOpts });
803
+ activeQueries.set(queryKey, { abort: () => abortController.abort() });
784
804
 
785
- for await (const sdkMsg of q) {
786
- if (ws.readyState !== 1) break;
805
+ let claudeSessionId = null;
806
+ let sessionModel = null;
787
807
 
788
- if (sdkMsg.type === "system" && sdkMsg.subtype === "init") {
789
- claudeSessionId = sdkMsg.session_id;
790
- if (sdkMsg.model) sessionModel = sdkMsg.model;
791
- const ourSid = clientSid || crypto.randomUUID();
792
- resolvedSid = ourSid;
808
+ for await (const sdkMsg of q) {
809
+ if (ws.readyState !== 1) break;
793
810
 
794
- const sKey = chatId ? `${ourSid}::${chatId}` : ourSid;
795
- sessionIds.set(sKey, claudeSessionId);
811
+ if (sdkMsg.type === "system" && sdkMsg.subtype === "init") {
812
+ claudeSessionId = sdkMsg.session_id;
813
+ if (sdkMsg.model) sessionModel = sdkMsg.model;
814
+ const ourSid = clientSid || crypto.randomUUID();
815
+ state.resolvedSid = ourSid;
796
816
 
797
- if (!getSession(ourSid)) {
798
- createSession(ourSid, claudeSessionId, projectName || "Session", cwd || "");
799
- } else {
800
- updateClaudeSessionId(ourSid, claudeSessionId);
801
- }
817
+ const sKey = chatId ? `${ourSid}::${chatId}` : ourSid;
818
+ sessionIds.set(sKey, claudeSessionId);
802
819
 
803
- if (chatId) {
804
- setClaudeSession(ourSid, chatId, claudeSessionId);
805
- }
820
+ if (!getSession(ourSid)) {
821
+ createSession(ourSid, claudeSessionId, projectName || "Session", cwd || "");
822
+ } else {
823
+ updateClaudeSessionId(ourSid, claudeSessionId);
824
+ }
806
825
 
807
- wsSend({ type: "session", sessionId: ourSid });
808
- const userMsgData = { text: message };
809
- if (images?.length) {
810
- userMsgData.images = images.map(i => ({ name: i.name, data: i.data, mimeType: i.mimeType }));
811
- }
812
- addMessage(resolvedSid, "user", JSON.stringify(userMsgData), chatId || null);
826
+ if (chatId) {
827
+ setClaudeSession(ourSid, chatId, claudeSessionId);
828
+ }
813
829
 
814
- // Register global query tracking now that we know the session
815
- if (!clientSid) registerGlobalQuery(resolvedSid, queryKey);
830
+ wsSend({ type: "session", sessionId: ourSid });
831
+ const userMsgData = { text: message };
832
+ if (images?.length) {
833
+ userMsgData.images = images.map(i => ({ name: i.name, data: i.data, mimeType: i.mimeType }));
834
+ }
835
+ addMessage(state.resolvedSid, "user", JSON.stringify(userMsgData), chatId || null);
816
836
 
817
- const existingSession = getSession(ourSid);
818
- if (existingSession && !existingSession.title) {
819
- const title = message.slice(0, 100).split("\n")[0];
820
- updateSessionTitle(ourSid, title);
821
- }
822
- continue;
823
- }
837
+ // Register global query tracking now that we know the session
838
+ if (!clientSid) registerGlobalQuery(state.resolvedSid, queryKey);
824
839
 
825
- if (sdkMsg.type === "assistant" && sdkMsg.message?.content) {
826
- for (const block of sdkMsg.message.content) {
827
- if (block.type === "text" && block.text) {
828
- lastAssistantText += (lastAssistantText ? "\n\n" : "") + block.text;
829
- wsSend({ type: "text", text: block.text });
830
- if (resolvedSid) addMessage(resolvedSid, "assistant", JSON.stringify({ text: block.text }), chatId || null);
831
- } else if (block.type === "tool_use") {
832
- wsSend({ type: "tool", id: block.id, name: block.name, input: block.input });
833
- if (resolvedSid) addMessage(resolvedSid, "tool", JSON.stringify({ id: block.id, name: block.name, input: block.input }), chatId || null);
834
- }
835
- }
836
- continue;
840
+ const existingSession = getSession(ourSid);
841
+ if (existingSession && !existingSession.title) {
842
+ const title = message.slice(0, 100).split("\n")[0];
843
+ updateSessionTitle(ourSid, title);
844
+ }
845
+ continue;
846
+ }
847
+
848
+ if (sdkMsg.type === "assistant" && sdkMsg.message?.content) {
849
+ for (const block of sdkMsg.message.content) {
850
+ if (block.type === "text" && block.text) {
851
+ state.lastAssistantText += (state.lastAssistantText ? "\n\n" : "") + block.text;
852
+ wsSend({ type: "text", text: block.text });
853
+ if (state.resolvedSid) addMessage(state.resolvedSid, "assistant", JSON.stringify({ text: block.text }), chatId || null);
854
+ } else if (block.type === "tool_use") {
855
+ 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);
837
857
  }
858
+ }
859
+ continue;
860
+ }
838
861
 
839
- if (sdkMsg.type === "result") {
840
- if (sdkMsg.subtype === "success") {
841
- const sid = resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
842
- const inputTokens = sdkMsg.usage?.input_tokens || 0;
843
- const outputTokens = sdkMsg.usage?.output_tokens || 0;
844
- const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
845
- const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
846
- const model = Object.keys(sdkMsg.modelUsage || {})[0] || sessionModel;
847
- 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 });
848
- 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" });
849
- lastChatMetrics = { durationMs: sdkMsg.duration_ms, costUsd: sdkMsg.total_cost_usd, inputTokens, outputTokens, model, turns: sdkMsg.num_turns, isError: false };
850
- if (resolvedSid) addMessage(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);
851
- } else if (sdkMsg.subtype === "error_max_turns") {
852
- // Max turns reached — treat as a normal completion with a notice
853
- const sid = resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
854
- const inputTokens = sdkMsg.usage?.input_tokens || 0;
855
- const outputTokens = sdkMsg.usage?.output_tokens || 0;
856
- const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
857
- const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
858
- const model = Object.keys(sdkMsg.modelUsage || {})[0] || sessionModel;
859
- 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 });
860
- 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" });
861
- wsSend({ type: "error", error: `Reached max turns limit (${sdkMsg.num_turns}). Send another message to continue.` });
862
- } else if (sdkMsg.subtype?.startsWith("error")) {
863
- const errMsg = sdkMsg.errors?.join(", ") || sdkMsg.error || sdkMsg.message || "Unknown error";
864
- console.error("SDK result error:", JSON.stringify(sdkMsg));
865
- const costUsd = sdkMsg.total_cost_usd || 0;
866
- const durationMs = sdkMsg.duration_ms || 0;
867
- const numTurns = sdkMsg.num_turns || 0;
868
- const inputTokens = sdkMsg.usage?.input_tokens || 0;
869
- const outputTokens = sdkMsg.usage?.output_tokens || 0;
870
- const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
871
- const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
872
- const model = Object.keys(sdkMsg.modelUsage || {})[0] || sessionModel;
873
- const sid = resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
874
- lastChatMetrics = { durationMs, costUsd, inputTokens, outputTokens, model, turns: numTurns, isError: true, error: errMsg };
875
- if (sid) {
876
- addCost(sid, costUsd, durationMs, numTurns, inputTokens, outputTokens, { model, stopReason: sdkMsg.subtype, isError: 1, cacheReadTokens, cacheCreationTokens });
877
- addMessage(sid, "error", JSON.stringify({ error: errMsg, subtype: sdkMsg.subtype, duration_ms: durationMs, cost_usd: costUsd, model }), chatId || null);
878
- }
879
- wsSend({ type: "error", error: errMsg });
880
- }
881
- continue;
862
+ if (sdkMsg.type === "result") {
863
+ if (sdkMsg.subtype === "success") {
864
+ const sid = state.resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
865
+ const inputTokens = sdkMsg.usage?.input_tokens || 0;
866
+ const outputTokens = sdkMsg.usage?.output_tokens || 0;
867
+ const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
868
+ const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
869
+ 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" });
872
+ 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);
874
+ } else if (sdkMsg.subtype === "error_max_turns") {
875
+ // Max turns reached — treat as a normal completion with a notice
876
+ const sid = state.resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
877
+ const inputTokens = sdkMsg.usage?.input_tokens || 0;
878
+ const outputTokens = sdkMsg.usage?.output_tokens || 0;
879
+ const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
880
+ const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
881
+ 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" });
884
+ wsSend({ type: "error", error: `Reached max turns limit (${sdkMsg.num_turns}). Send another message to continue.` });
885
+ } else if (sdkMsg.subtype?.startsWith("error")) {
886
+ const errMsg = sdkMsg.errors?.join(", ") || sdkMsg.error || sdkMsg.message || "Unknown error";
887
+ console.error("SDK result error:", JSON.stringify(sdkMsg));
888
+ const costUsd = sdkMsg.total_cost_usd || 0;
889
+ const durationMs = sdkMsg.duration_ms || 0;
890
+ const numTurns = sdkMsg.num_turns || 0;
891
+ const inputTokens = sdkMsg.usage?.input_tokens || 0;
892
+ const outputTokens = sdkMsg.usage?.output_tokens || 0;
893
+ const cacheReadTokens = sdkMsg.usage?.cache_read_input_tokens || 0;
894
+ const cacheCreationTokens = sdkMsg.usage?.cache_creation_input_tokens || 0;
895
+ const model = Object.keys(sdkMsg.modelUsage || {})[0] || sessionModel;
896
+ const sid = state.resolvedSid || [...sessionIds.entries()].find(([, v]) => v === claudeSessionId)?.[0];
897
+ state.lastChatMetrics = { durationMs, costUsd, inputTokens, outputTokens, model, turns: numTurns, isError: true, error: errMsg };
898
+ 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);
882
901
  }
902
+ wsSend({ type: "error", error: errMsg });
903
+ }
904
+ continue;
905
+ }
883
906
 
884
- if (sdkMsg.type === "user" && sdkMsg.message?.content) {
885
- const blocks = Array.isArray(sdkMsg.message.content) ? sdkMsg.message.content : [];
886
- for (const block of blocks) {
887
- if (block.type === "tool_result") {
888
- const text = Array.isArray(block.content) ? block.content.map(c => c.type === "text" ? c.text : "").join("") : typeof block.content === "string" ? block.content : "";
889
- const wirePayload = { toolUseId: block.tool_use_id, content: text.slice(0, 2000), isError: block.is_error || false };
890
- wsSend({ type: "tool_result", ...wirePayload });
891
- if (resolvedSid) {
892
- const dbPayload = { toolUseId: block.tool_use_id, content: text.slice(0, 10000), isError: block.is_error || false };
893
- addMessage(resolvedSid, "tool_result", JSON.stringify(dbPayload), chatId || null);
894
- }
895
- }
907
+ if (sdkMsg.type === "user" && sdkMsg.message?.content) {
908
+ const blocks = Array.isArray(sdkMsg.message.content) ? sdkMsg.message.content : [];
909
+ for (const block of blocks) {
910
+ if (block.type === "tool_result") {
911
+ const text = Array.isArray(block.content) ? block.content.map(c => c.type === "text" ? c.text : "").join("") : typeof block.content === "string" ? block.content : "";
912
+ const wirePayload = { toolUseId: block.tool_use_id, content: text.slice(0, 2000), isError: block.is_error || false };
913
+ wsSend({ type: "tool_result", ...wirePayload });
914
+ if (state.resolvedSid) {
915
+ 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);
896
917
  }
897
- continue;
898
918
  }
899
919
  }
920
+ continue;
900
921
  }
922
+ }
923
+ }
901
924
 
902
- try {
903
- await runQuery(opts);
904
- wsSend({ type: "done" });
905
- } catch (err) {
906
- if (err.name === "AbortError") {
907
- if (resolvedSid) addMessage(resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
908
- wsSend({ type: "aborted" });
909
- } else {
910
- const stderrOutput = stderrChunks.join("");
911
- // Retry without resume if the Claude session no longer exists
912
- if (opts.resume && stderrOutput.includes("No conversation found")) {
913
- console.warn("Stale session", opts.resume, "— retrying without resume");
914
- delete opts.resume;
915
- sessionIds.delete(sessionKey);
916
- stderrChunks.length = 0;
917
- try {
918
- await runQuery(opts);
919
- wsSend({ type: "done" });
920
- } catch (retryErr) {
921
- if (retryErr.name === "AbortError") {
922
- if (resolvedSid) addMessage(resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
923
- wsSend({ type: "aborted" });
924
- } else {
925
- console.error("Query retry error:", retryErr.message);
926
- wsSend({ type: "error", error: retryErr.message });
927
- }
928
- }
925
+ try {
926
+ await runChatQuery(opts);
927
+ wsSend({ type: "done" });
928
+ } catch (err) {
929
+ if (err.name === "AbortError") {
930
+ if (state.resolvedSid) addMessage(state.resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
931
+ wsSend({ type: "aborted" });
932
+ } else {
933
+ const stderrOutput = stderrChunks.join("");
934
+ // Retry without resume if the Claude session no longer exists
935
+ if (opts.resume && stderrOutput.includes("No conversation found")) {
936
+ console.warn("Stale session", opts.resume, "— retrying without resume");
937
+ delete opts.resume;
938
+ sessionIds.delete(sessionKey);
939
+ stderrChunks.length = 0;
940
+ try {
941
+ await runChatQuery(opts);
942
+ wsSend({ type: "done" });
943
+ } catch (retryErr) {
944
+ if (retryErr.name === "AbortError") {
945
+ if (state.resolvedSid) addMessage(state.resolvedSid, "aborted", JSON.stringify({ timestamp: Date.now() }), chatId || null);
946
+ wsSend({ type: "aborted" });
929
947
  } else {
930
- console.error("Query error:", err.message, stderrOutput ? "\nstderr: " + stderrOutput : "");
931
- wsSend({ type: "error", error: err.message });
948
+ console.error("Query retry error:", retryErr.message);
949
+ wsSend({ type: "error", error: retryErr.message });
932
950
  }
933
951
  }
934
- } finally {
935
- activeQueries.delete(queryKey);
936
- unregisterGlobalQuery(resolvedSid, queryKey);
937
- // Send push notification when query completes
938
- const session = resolvedSid ? getSession(resolvedSid) : null;
939
- const pushTitle = session?.title || "Session complete";
940
- sendPushNotification("Claudeck", pushTitle, `chat-${resolvedSid}`);
941
-
942
- // Rich Telegram notification meaningful for AFK developer
943
- const userQuery = (message || "").slice(0, 150).split("\n")[0];
944
- const answerSnippet = lastAssistantText
945
- ? lastAssistantText.slice(0, 300).replace(/\n{2,}/g, "\n")
946
- : "";
947
-
948
- if (lastChatMetrics.isError) {
949
- const errorBody = [
950
- userQuery ? `Q: ${userQuery}` : "",
951
- `Error: ${lastChatMetrics.error || "Unknown error"}`,
952
- ].filter(Boolean).join("\n");
953
- sendTelegramNotification("error", "Session Failed", errorBody, {
954
- durationMs: lastChatMetrics.durationMs,
955
- costUsd: lastChatMetrics.costUsd,
956
- inputTokens: lastChatMetrics.inputTokens,
957
- outputTokens: lastChatMetrics.outputTokens,
958
- model: lastChatMetrics.model,
959
- });
960
- } else {
961
- const body = [
962
- userQuery ? `Q: ${userQuery}` : pushTitle,
963
- answerSnippet ? `\nA: ${answerSnippet}` : "",
964
- ].filter(Boolean).join("\n");
965
- sendTelegramNotification("session", "Session Complete", body, {
966
- durationMs: lastChatMetrics.durationMs,
967
- costUsd: lastChatMetrics.costUsd,
968
- inputTokens: lastChatMetrics.inputTokens,
969
- outputTokens: lastChatMetrics.outputTokens,
970
- model: lastChatMetrics.model,
971
- turns: lastChatMetrics.turns,
972
- });
973
- }
952
+ } else {
953
+ console.error("Query error:", err.message, stderrOutput ? "\nstderr: " + stderrOutput : "");
954
+ wsSend({ type: "error", error: err.message });
955
+ }
956
+ }
957
+ } finally {
958
+ activeQueries.delete(queryKey);
959
+ unregisterGlobalQuery(state.resolvedSid, queryKey);
960
+ // Send push notification when query completes
961
+ const session = state.resolvedSid ? getSession(state.resolvedSid) : null;
962
+ const pushTitle = session?.title || "Session complete";
963
+ sendPushNotification("Claudeck", pushTitle, `chat-${state.resolvedSid}`);
964
+
965
+ // Rich Telegram notification — meaningful for AFK developer
966
+ const userQuery = (message || "").slice(0, 150).split("\n")[0];
967
+ const answerSnippet = state.lastAssistantText
968
+ ? state.lastAssistantText.slice(0, 300).replace(/\n{2,}/g, "\n")
969
+ : "";
970
+
971
+ if (state.lastChatMetrics.isError) {
972
+ const errorBody = [
973
+ userQuery ? `Q: ${userQuery}` : "",
974
+ `Error: ${state.lastChatMetrics.error || "Unknown error"}`,
975
+ ].filter(Boolean).join("\n");
976
+ sendTelegramNotification("error", "Session Failed", errorBody, {
977
+ durationMs: state.lastChatMetrics.durationMs,
978
+ costUsd: state.lastChatMetrics.costUsd,
979
+ inputTokens: state.lastChatMetrics.inputTokens,
980
+ outputTokens: state.lastChatMetrics.outputTokens,
981
+ model: state.lastChatMetrics.model,
982
+ });
983
+ } else {
984
+ const body = [
985
+ userQuery ? `Q: ${userQuery}` : pushTitle,
986
+ answerSnippet ? `\nA: ${answerSnippet}` : "",
987
+ ].filter(Boolean).join("\n");
988
+ sendTelegramNotification("session", "Session Complete", body, {
989
+ durationMs: state.lastChatMetrics.durationMs,
990
+ costUsd: state.lastChatMetrics.costUsd,
991
+ inputTokens: state.lastChatMetrics.inputTokens,
992
+ outputTokens: state.lastChatMetrics.outputTokens,
993
+ model: state.lastChatMetrics.model,
994
+ turns: state.lastChatMetrics.turns,
995
+ });
996
+ }
974
997
 
975
- // Fire-and-forget summary generation
976
- if (resolvedSid) {
977
- generateSessionSummary(resolvedSid).catch(err =>
978
- console.error("Summary generation error:", err.message)
979
- );
998
+ // Fire-and-forget summary generation
999
+ if (state.resolvedSid) {
1000
+ generateSessionSummary(state.resolvedSid).catch(err =>
1001
+ console.error("Summary generation error:", err.message)
1002
+ );
1003
+ }
1004
+
1005
+ // Auto-capture memories from assistant output
1006
+ if (cwd && state.lastAssistantText) {
1007
+ try {
1008
+ // 1. Parse explicit ```memory blocks (Claude-requested saves)
1009
+ const explicitCount = saveExplicitMemories(cwd, state.lastAssistantText, state.resolvedSid);
1010
+
1011
+ // 2. Heuristic extraction from assistant text
1012
+ const autoCount = captureMemories(cwd, state.lastAssistantText, state.resolvedSid, null);
1013
+
1014
+ const totalCaptured = explicitCount + autoCount;
1015
+ if (totalCaptured > 0) {
1016
+ console.log(`Captured ${totalCaptured} memories (${explicitCount} explicit, ${autoCount} auto) from session ${state.resolvedSid}`);
1017
+ // Notify client
1018
+ if (ws.readyState === 1) {
1019
+ const payload = { type: "memories_captured", count: totalCaptured, explicit: explicitCount, auto: autoCount };
1020
+ if (chatId) payload.chatId = chatId;
1021
+ ws.send(JSON.stringify(payload));
1022
+ }
980
1023
  }
1024
+ } catch (e) { console.error("Memory capture error:", e.message); }
1025
+ }
981
1026
 
982
- // Auto-capture memories from assistant output
983
- if (cwd && lastAssistantText) {
984
- try {
985
- // 1. Parse explicit ```memory blocks (Claude-requested saves)
986
- const explicitCount = saveExplicitMemories(cwd, lastAssistantText, resolvedSid);
987
-
988
- // 2. Heuristic extraction from assistant text
989
- const autoCount = captureMemories(cwd, lastAssistantText, resolvedSid, null);
990
-
991
- const totalCaptured = explicitCount + autoCount;
992
- if (totalCaptured > 0) {
993
- console.log(`Captured ${totalCaptured} memories (${explicitCount} explicit, ${autoCount} auto) from session ${resolvedSid}`);
994
- // Notify client
995
- if (ws.readyState === 1) {
996
- const payload = { type: "memories_captured", count: totalCaptured, explicit: explicitCount, auto: autoCount };
997
- if (chatId) payload.chatId = chatId;
998
- ws.send(JSON.stringify(payload));
999
- }
1000
- }
1001
- } catch (e) { console.error("Memory capture error:", e.message); }
1027
+ // Worktree post-completion: auto-commit, diff stats, notify
1028
+ if (worktreeRecord) {
1029
+ try {
1030
+ if (state.resolvedSid) updateWorktreeSession(worktreeRecord.id, state.resolvedSid);
1031
+
1032
+ const commitMsg = `claudeck: ${(message || "worktree changes").slice(0, 72)}`;
1033
+ await autoCommitWorktree(worktreeRecord.worktreePath, commitMsg);
1034
+
1035
+ const stats = await getWorktreeDiffStats(worktreeRecord.worktreePath, worktreeRecord.baseBranch);
1036
+ updateWorktreeStatus(worktreeRecord.id, "completed");
1037
+
1038
+ if (ws.readyState === 1) {
1039
+ const wtPayload = {
1040
+ type: "worktree_completed",
1041
+ worktreeId: worktreeRecord.id,
1042
+ branchName: worktreeRecord.branchName,
1043
+ stats,
1044
+ };
1045
+ if (chatId) wtPayload.chatId = chatId;
1046
+ ws.send(JSON.stringify(wtPayload));
1002
1047
  }
1048
+
1049
+ logNotification(
1050
+ "worktree",
1051
+ `Worktree "${worktreeRecord.branchName}" ready`,
1052
+ `+${stats.insertions} -${stats.deletions} lines in ${stats.files} file(s)`,
1053
+ JSON.stringify({ worktreeId: worktreeRecord.id, branchName: worktreeRecord.branchName }),
1054
+ state.resolvedSid,
1055
+ null
1056
+ );
1057
+ } catch (e) {
1058
+ console.error("Worktree post-completion error:", e.message);
1003
1059
  }
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ // ── Main entry point: thin router ─────────────────────────────────────────
1065
+ export function setupWebSocket(wss, sessionIds) {
1066
+ wss.on("connection", (ws) => {
1067
+ const ctx = { ws, sessionIds, activeQueries: new Map(), pendingApprovals: new Map() };
1068
+
1069
+ ws.on("close", () => handleClose(ctx));
1070
+
1071
+ ws.on("message", async (raw) => {
1072
+ let msg;
1073
+ try { msg = JSON.parse(raw); } catch { return; }
1074
+
1075
+ if (msg.type === "abort") return handleAbort(msg, ctx);
1076
+ if (msg.type === "permission_response") return handlePermissionResponse(msg, ctx);
1077
+ if (msg.type === "workflow") return handleWorkflow(msg, ctx);
1078
+ if (msg.type === "agent") return handleAgent(msg, ctx);
1079
+ if (msg.type === "agent_chain") return handleAgentChain(msg, ctx);
1080
+ if (msg.type === "agent_dag") return handleDag(msg, ctx);
1081
+ if (msg.type === "orchestrate") return handleOrchestrate(msg, ctx);
1082
+ if (msg.type === "chat") return handleChat(msg, ctx);
1004
1083
  });
1005
1084
  });
1006
1085
  }