clay-server 2.23.1-beta.1 → 2.23.1

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.
@@ -358,18 +358,20 @@ function attachDebate(ctx) {
358
358
  console.log("[debate] Restoring debate (preparing). topic:", debate.topic, "briefPath:", briefPath);
359
359
  startDebateBriefWatcher(session, debate, briefPath);
360
360
 
361
- // Send preparing sticky to the connected client
362
- ctx.sendTo(ws, {
363
- type: "debate_preparing",
364
- topic: debate.topic,
365
- moderatorId: debate.moderatorId,
366
- moderatorName: moderatorProfile.name,
367
- setupSessionId: debate.setupSessionId,
368
- panelists: debate.panelists.map(function (p) {
369
- var prof = ctx.getMateProfile(mateCtx, p.mateId);
370
- return { mateId: p.mateId, name: prof.name };
371
- }),
372
- });
361
+ // Only show preparing indicator for quick start (standard setup shows skill in real-time)
362
+ if (debate.quickStart) {
363
+ ctx.sendTo(ws, {
364
+ type: "debate_preparing",
365
+ topic: debate.topic,
366
+ moderatorId: debate.moderatorId,
367
+ moderatorName: moderatorProfile.name,
368
+ setupSessionId: debate.setupSessionId,
369
+ panelists: debate.panelists.map(function (p) {
370
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
371
+ return { mateId: p.mateId, name: prof.name };
372
+ }),
373
+ });
374
+ }
373
375
  } else if (phase === "reviewing") {
374
376
  console.log("[debate] Restoring debate (reviewing). topic:", debate.topic);
375
377
  ctx.sendTo(ws, {
@@ -559,6 +561,7 @@ function attachDebate(ctx) {
559
561
 
560
562
  // Quick start: moderator mate uses DM conversation context to generate the debate brief directly
561
563
  function handleDebateQuickStart(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath) {
564
+ debate.quickStart = true;
562
565
  var debateId = debate.debateId;
563
566
 
564
567
  // Create setup session (still needed for session grouping)
@@ -720,23 +723,8 @@ function attachDebate(ctx) {
720
723
  // Watch for brief.json in the debate-specific directory
721
724
  startDebateBriefWatcher(session, debate, briefPath);
722
725
 
723
- // Notify clients that we are in preparing phase (send to both original and setup session)
724
- var preparingMsg = {
725
- type: "debate_preparing",
726
- topic: debate.topic,
727
- moderatorId: debate.moderatorId,
728
- moderatorName: moderatorProfile.name,
729
- setupSessionId: setupSession.localId,
730
- panelists: debate.panelists.map(function (p) {
731
- var prof = ctx.getMateProfile(mateCtx, p.mateId);
732
- return { mateId: p.mateId, name: prof.name };
733
- }),
734
- };
735
- // Send directly to the requesting ws (session switch may not have propagated yet)
736
- ctx.sendTo(ws, preparingMsg);
737
- // Also broadcast to any other clients on either session
738
- ctx.sendToSession(session.localId, preparingMsg);
739
- ctx.sendToSession(setupSession.localId, preparingMsg);
726
+ // Standard setup: no preparing indicator needed because the user
727
+ // sees the skill working in real-time in the setup session.
740
728
 
741
729
  // Start the setup skill session
742
730
  setupSession.history.push({ type: "user_message", text: craftingPrompt });
package/lib/project.js CHANGED
@@ -3786,7 +3786,8 @@ function createProjectContext(opts) {
3786
3786
  onProcessingChanged();
3787
3787
  session.sentToolResults = {};
3788
3788
  sendToSession(session.localId, { type: "status", status: "processing" });
3789
- if (!session.queryInstance && !session.worker) {
3789
+ if (!session.queryInstance && (!session.worker || session.messageQueue !== "worker")) {
3790
+ // No active query (or worker idle between queries): start a new query
3790
3791
  sdk.startQuery(session, fullText, msg.images, getLinuxUserForSession(session));
3791
3792
  } else {
3792
3793
  sdk.pushMessage(session, fullText, msg.images);
@@ -5505,6 +5506,10 @@ function createProjectContext(opts) {
5505
5506
  if (session.messageQueue) {
5506
5507
  try { session.messageQueue.end(); } catch (e) {}
5507
5508
  }
5509
+ if (session.worker) {
5510
+ try { session.worker.kill(); } catch (e) {}
5511
+ session.worker = null;
5512
+ }
5508
5513
  // Close all mention SDK sessions to prevent zombie processes
5509
5514
  if (session._mentionSessions) {
5510
5515
  var mateIds = Object.keys(session._mentionSessions);
package/lib/public/app.js CHANGED
@@ -31,7 +31,7 @@ import { initMateWizard, openMateWizard, closeMateWizard, handleMateCreated } fr
31
31
  import { initCommandPalette, handlePaletteSessionSwitch, setPaletteVersion } from './modules/command-palette.js';
32
32
  import { initLongPress } from './modules/longpress.js';
33
33
  import { initMention, handleMentionStart, handleMentionStream, handleMentionDone, handleMentionError, handleMentionActivity, renderMentionUser, renderMentionResponse } from './modules/mention.js';
34
- import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateResumed, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, handleDebateEnded, handleDebateError, renderDebateStarted, renderDebateTurnDone, renderDebateEnded, renderDebateCommentInjected, renderDebateUserResume, openDebateModal, closeDebateModal, openQuickDebateModal, handleDebateBriefReady, renderDebateBriefReady, isDebateActive, resetDebateState } from './modules/debate.js';
34
+ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateResumed, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, handleDebateEnded, handleDebateError, renderDebateStarted, renderDebateTurnDone, renderDebateEnded, renderDebateCommentInjected, renderDebateUserResume, openDebateModal, closeDebateModal, handleDebateBriefReady, renderDebateBriefReady, isDebateActive, resetDebateState } from './modules/debate.js';
35
35
 
36
36
  // --- Base path for multi-project routing ---
37
37
  var slugMatch = location.pathname.match(/^\/p\/([a-z0-9_-]+)/);
@@ -5413,6 +5413,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
5413
5413
  matesList: function () { return cachedMatesList || []; },
5414
5414
  availableBuiltins: function () { return cachedAvailableBuiltins || []; },
5415
5415
  currentMateId: function () { return (dmTargetUser && dmTargetUser.isMate) ? dmTargetUser.id : null; },
5416
+ requireSkills: requireSkills,
5416
5417
  });
5417
5418
 
5418
5419
  // --- STT module (voice input via Web Speech API) ---
@@ -6168,30 +6169,15 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
6168
6169
  }, cb);
6169
6170
  }
6170
6171
 
6171
- function requireClayDebateSetup(cb) {
6172
- requireSkills({
6173
- title: "Skill Installation Required",
6174
- reason: "The Debate Setup skill is required to start a debate.",
6175
- skills: [{ name: "clay-debate-setup", url: "https://github.com/chadbyte/clay-debate-setup", scope: "global" }]
6176
- }, cb);
6177
- }
6178
6172
 
6179
- // Debate button in mate sidebar
6173
+ // Debate button in mate sidebar (only visible in DM mode)
6180
6174
  var debateBtn = document.getElementById("mate-debate-btn");
6181
6175
  if (debateBtn) {
6182
6176
  debateBtn.addEventListener("click", function () {
6183
- if (dmMode && dmTargetUser && dmTargetUser.isMate) {
6184
- // Quick debate: moderator is the current DM mate, uses conversation context
6185
- // Build messages with isMate flag for context extraction
6186
- var contextMessages = dmMessageCache.map(function (m) {
6187
- return { text: m.text, isMate: m.from !== myUserId };
6188
- });
6189
- openQuickDebateModal(contextMessages);
6190
- } else {
6191
- requireClayDebateSetup(function () {
6192
- openDebateModal();
6193
- });
6194
- }
6177
+ var contextMessages = dmMessageCache.map(function (m) {
6178
+ return { text: m.text, isMate: m.from !== myUserId };
6179
+ });
6180
+ openDebateModal({ dmContext: contextMessages });
6195
6181
  });
6196
6182
  }
6197
6183
 
@@ -910,6 +910,31 @@
910
910
  line-height: 1.4;
911
911
  }
912
912
 
913
+ .debate-skip-setup-row {
914
+ margin-top: 12px;
915
+ padding: 8px 0;
916
+ }
917
+
918
+ .debate-toggle-label {
919
+ display: flex;
920
+ align-items: center;
921
+ gap: 8px;
922
+ font-size: 13px;
923
+ color: var(--text-secondary);
924
+ cursor: pointer;
925
+ }
926
+
927
+ .debate-toggle-label input[type="checkbox"] {
928
+ margin: 0;
929
+ cursor: pointer;
930
+ }
931
+
932
+ .debate-skip-setup-hint {
933
+ font-size: 11px;
934
+ color: var(--text-muted);
935
+ margin-left: 4px;
936
+ }
937
+
913
938
  .debate-modal-footer {
914
939
  display: flex;
915
940
  justify-content: flex-end;
@@ -1433,6 +1433,13 @@
1433
1433
  <textarea id="debate-topic-input" rows="2" placeholder="e.g. Should AI development be regulated?"></textarea>
1434
1434
  <label class="debate-field-label" for="debate-panel-list">Select panelists</label>
1435
1435
  <div id="debate-panel-list" class="debate-panel-list"></div>
1436
+ <div id="debate-skip-setup-row" class="debate-skip-setup-row hidden">
1437
+ <label class="debate-toggle-label">
1438
+ <input type="checkbox" id="debate-skip-setup-toggle">
1439
+ <span>Skip setup</span>
1440
+ <span class="debate-skip-setup-hint">Moderator generates the brief automatically from conversation context</span>
1441
+ </label>
1442
+ </div>
1436
1443
  </div>
1437
1444
  <div class="debate-modal-footer">
1438
1445
  <button id="debate-modal-cancel" class="debate-btn-cancel">Cancel</button>
@@ -535,7 +535,8 @@ export function isDebateActive() {
535
535
  var modalEl = null;
536
536
  var selectedPanelists = [];
537
537
 
538
- export function openDebateModal() {
538
+ export function openDebateModal(opts) {
539
+ opts = opts || {};
539
540
  modalEl = document.getElementById("debate-modal");
540
541
  if (!modalEl) return;
541
542
 
@@ -547,6 +548,46 @@ export function openDebateModal() {
547
548
  topicInput.focus();
548
549
  }
549
550
 
551
+ // Show/hide skip-setup toggle (only when dmContext is available)
552
+ var skipSetupRow = document.getElementById("debate-skip-setup-row");
553
+ var skipSetupToggle = document.getElementById("debate-skip-setup-toggle");
554
+ if (skipSetupRow) {
555
+ if (opts.dmContext) {
556
+ skipSetupRow.classList.remove("hidden");
557
+ } else {
558
+ skipSetupRow.classList.add("hidden");
559
+ }
560
+ }
561
+ if (skipSetupToggle) {
562
+ skipSetupToggle.checked = false;
563
+ skipSetupToggle.onchange = function () {
564
+ var topicLabel = modalEl.querySelector('.debate-field-label[for="debate-topic-input"]');
565
+ var reqSpan = topicLabel ? topicLabel.querySelector(".debate-field-req") : null;
566
+ if (skipSetupToggle.checked) {
567
+ if (topicInput) topicInput.placeholder = "Leave blank to auto-detect from conversation...";
568
+ if (reqSpan) reqSpan.style.display = "none";
569
+ } else {
570
+ if (topicInput) topicInput.placeholder = "e.g. Should AI development be regulated?";
571
+ if (reqSpan) reqSpan.style.display = "";
572
+ }
573
+ };
574
+ }
575
+
576
+ // Build DM context string for quick start
577
+ var dmContextStr = "";
578
+ if (opts.dmContext && opts.dmContext.length) {
579
+ var recent = opts.dmContext.slice(-20);
580
+ var parts = [];
581
+ for (var i = 0; i < recent.length; i++) {
582
+ var m = recent[i];
583
+ var speaker = m.isMate ? "Mate" : "User";
584
+ var text = m.text || "";
585
+ if (text.length > 500) text = text.substring(0, 500) + "...";
586
+ parts.push(speaker + ": " + text);
587
+ }
588
+ dmContextStr = parts.join("\n");
589
+ }
590
+
550
591
  // Populate panelist list from mates (exclude current mate = moderator)
551
592
  var panelList = document.getElementById("debate-panel-list");
552
593
  if (panelList) {
@@ -584,7 +625,9 @@ export function openDebateModal() {
584
625
  if (startBtn) {
585
626
  startBtn.onclick = function () {
586
627
  var topic = topicInput ? topicInput.value.trim() : "";
587
- if (!topic) {
628
+ var skipSetup = skipSetupToggle && skipSetupToggle.checked;
629
+
630
+ if (!skipSetup && !topic) {
588
631
  topicInput.focus();
589
632
  return;
590
633
  }
@@ -593,32 +636,54 @@ export function openDebateModal() {
593
636
  var currentMateId = ctx.currentMateId ? ctx.currentMateId() : null;
594
637
  if (!currentMateId) return;
595
638
 
596
- // Create a new session first, then send debate_start after switch
597
- if (ctx.ws) {
598
- var debatePayload = {
599
- type: "debate_start",
600
- moderatorId: currentMateId,
601
- topic: topic,
602
- panelists: selectedPanelists.map(function (id) {
603
- return { mateId: id, role: "", brief: "" };
604
- }),
605
- };
606
-
607
- // Listen for session_switched once, then send debate_start
608
- var onMessage = function (evt) {
609
- try {
610
- var data = JSON.parse(evt.data);
611
- if (data.type === "session_switched") {
612
- ctx.ws.removeEventListener("message", onMessage);
613
- ctx.ws.send(JSON.stringify(debatePayload));
614
- }
615
- } catch (e) {}
616
- };
617
- ctx.ws.addEventListener("message", onMessage);
618
- ctx.ws.send(JSON.stringify({ type: "new_session" }));
639
+ var debatePayload = {
640
+ type: "debate_start",
641
+ moderatorId: currentMateId,
642
+ topic: topic || (skipSetup ? "(auto-detect from conversation)" : ""),
643
+ panelists: selectedPanelists.map(function (id) {
644
+ return { mateId: id, role: "", brief: "" };
645
+ }),
646
+ };
647
+
648
+ // When skip setup is toggled, send as quick start with DM context
649
+ if (skipSetup) {
650
+ debatePayload.quickStart = true;
651
+ debatePayload.dmContext = dmContextStr;
652
+ }
653
+
654
+ function sendDebate() {
655
+ // Create a new session first, then send debate_start after switch
656
+ if (ctx.ws) {
657
+ var onMessage = function (evt) {
658
+ try {
659
+ var data = JSON.parse(evt.data);
660
+ if (data.type === "session_switched") {
661
+ ctx.ws.removeEventListener("message", onMessage);
662
+ ctx.ws.send(JSON.stringify(debatePayload));
663
+ }
664
+ } catch (e) {}
665
+ };
666
+ ctx.ws.addEventListener("message", onMessage);
667
+ ctx.ws.send(JSON.stringify({ type: "new_session" }));
668
+ }
669
+ closeDebateModal();
619
670
  }
620
671
 
621
- closeDebateModal();
672
+ if (skipSetup) {
673
+ // Quick start: no skill needed
674
+ sendDebate();
675
+ } else {
676
+ // Standard: require clay-debate-setup skill first
677
+ if (ctx.requireSkills) {
678
+ ctx.requireSkills({
679
+ title: "Skill Installation Required",
680
+ reason: "The Debate Setup skill is required to start a debate.",
681
+ skills: [{ name: "clay-debate-setup", url: "https://github.com/chadbyte/clay-debate-setup", scope: "global" }]
682
+ }, sendDebate);
683
+ } else {
684
+ sendDebate();
685
+ }
686
+ }
622
687
  };
623
688
  }
624
689
 
@@ -696,6 +761,10 @@ export function closeDebateModal() {
696
761
  modalEl.classList.add("hidden");
697
762
  }
698
763
  selectedPanelists = [];
764
+ var skipSetupToggle = document.getElementById("debate-skip-setup-toggle");
765
+ if (skipSetupToggle) skipSetupToggle.checked = false;
766
+ var skipSetupRow = document.getElementById("debate-skip-setup-row");
767
+ if (skipSetupRow) skipSetupRow.classList.add("hidden");
699
768
  }
700
769
 
701
770
  // --- Quick Debate: start debate from DM context ---
package/lib/sdk-bridge.js CHANGED
@@ -748,6 +748,13 @@ function createSDKBridge(opts) {
748
748
  worker._readyResolve = resolve;
749
749
  });
750
750
 
751
+ // Resolves when the worker process actually exits.
752
+ // Used to prevent spawning a new worker before the old one finishes
753
+ // flushing SDK session state to disk (race condition on resume).
754
+ worker.exitPromise = new Promise(function(resolve) {
755
+ worker._exitResolve = resolve;
756
+ });
757
+
751
758
  // Create Unix socket server
752
759
  worker.server = net.createServer(function(connection) {
753
760
  worker.connection = connection;
@@ -827,6 +834,7 @@ function createSDKBridge(opts) {
827
834
  // guards against stale workers. This covers all exit cases including
828
835
  // signal kills (code=null) and normal exits where the IPC query_error
829
836
  // was lost due to connection timing.
837
+ console.log("[sdk-bridge] Exit handler: pid=" + (worker.process ? worker.process.pid : "?") + " ready=" + worker.ready + " _queryEnded=" + worker._queryEnded + " _abortSent=" + worker._abortSent + " handlers=" + worker.messageHandlers.length);
830
838
  if (code === 0 && !worker.ready) {
831
839
  // Worker exited cleanly before sending "ready"
832
840
  for (var h = 0; h < worker.messageHandlers.length; h++) {
@@ -868,6 +876,10 @@ function createSDKBridge(opts) {
868
876
  }
869
877
  }
870
878
  cleanupWorker(worker);
879
+ if (worker._exitResolve) {
880
+ worker._exitResolve();
881
+ worker._exitResolve = null;
882
+ }
871
883
  });
872
884
  });
873
885
 
@@ -885,6 +897,7 @@ function createSDKBridge(opts) {
885
897
  };
886
898
 
887
899
  worker.kill = function() {
900
+ console.log("[sdk-bridge] worker.kill() called, pid=" + (worker.process ? worker.process.pid : "?") + " stack=" + new Error().stack.split("\n").slice(1, 4).join(" | "));
888
901
  worker.send({ type: "shutdown" });
889
902
  // Force kill after 5 seconds if still alive (gives SDK time to save session)
890
903
  setTimeout(function() {
@@ -903,6 +916,7 @@ function createSDKBridge(opts) {
903
916
  }
904
917
 
905
918
  function cleanupWorker(worker) {
919
+ console.log("[sdk-bridge] cleanupWorker() called, pid=" + (worker.process ? worker.process.pid : "?") + " stack=" + new Error().stack.split("\n").slice(1, 4).join(" | "));
906
920
  if (worker.connection && !worker.connection.destroyed) {
907
921
  try { worker.connection.end(); } catch (e) {}
908
922
  }
@@ -919,6 +933,53 @@ function createSDKBridge(opts) {
919
933
  * Mirrors the in-process startQuery flow but delegates SDK execution to the worker.
920
934
  */
921
935
  async function startQueryViaWorker(session, text, images, linuxUser) {
936
+ // Wait for the previous worker to fully exit before spawning a new one.
937
+ // Without this, the new worker may try to resume the SDK session file
938
+ // while the old worker is still flushing it to disk (800ms grace period),
939
+ // causing "no conversation found" and losing all prior context.
940
+ if (session._workerExitPromise) {
941
+ console.log("[sdk-bridge] startQueryViaWorker: waiting for old worker exit, localId=" + session.localId);
942
+ var exitWait = session._workerExitPromise;
943
+ session._workerExitPromise = null;
944
+ await Promise.race([
945
+ exitWait,
946
+ new Promise(function(resolve) { setTimeout(resolve, 3000); }),
947
+ ]);
948
+ console.log("[sdk-bridge] startQueryViaWorker: old worker exit wait done");
949
+ }
950
+
951
+ // Ensure the linux user's .claude directories are writable.
952
+ // The daemon runs as root, so any dirs it creates (mkdirSync) will be
953
+ // root-owned. The SDK (running as the linux user) needs write access
954
+ // to create session files, otherwise resume silently fails.
955
+ if (linuxUser) {
956
+ try {
957
+ var osUsersMod0 = require("./os-users");
958
+ var linuxUserHome0 = osUsersMod0.getLinuxUserHome(linuxUser);
959
+ var uid0 = osUsersMod0.getLinuxUserUid(linuxUser);
960
+ if (uid0 != null) {
961
+ var claudeDir = path.join(linuxUserHome0, ".claude");
962
+ var projectSlug0 = (cwd || "").replace(/\//g, "-");
963
+ var projDir = path.join(claudeDir, "projects", projectSlug0);
964
+ // Create the project directory if missing and chown the whole tree
965
+ if (!fs.existsSync(projDir)) {
966
+ fs.mkdirSync(projDir, { recursive: true });
967
+ try { require("child_process").execSync("chown -R " + uid0 + " " + JSON.stringify(claudeDir)); } catch (e) {}
968
+ } else {
969
+ // Fix existing directory if root-owned
970
+ try {
971
+ var pstat = fs.statSync(projDir);
972
+ if (pstat.uid !== uid0) {
973
+ require("child_process").execSync("chown " + uid0 + " " + JSON.stringify(projDir));
974
+ }
975
+ } catch (e) {}
976
+ }
977
+ }
978
+ } catch (e) {
979
+ console.log("[sdk-bridge] Dir ownership fix skipped:", e.message);
980
+ }
981
+ }
982
+
922
983
  // Pre-copy CLI session file BEFORE spawning worker.
923
984
  // Must happen before spawn so execSync doesn't block the event loop
924
985
  // while worker is alive (which causes ready/exit race conditions).
@@ -934,10 +995,26 @@ function createSDKBridge(opts) {
934
995
  var srcFile = path.join(originalHome, ".claude", "projects", projectSlug, sessionFileName);
935
996
  var dstDir = path.join(linuxUserHome, ".claude", "projects", projectSlug);
936
997
  var dstFile = path.join(dstDir, sessionFileName);
937
- if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
998
+ var uid = osUsersMod.getLinuxUserUid(linuxUser);
999
+ // Ensure the projects directory is owned by the linux user so the
1000
+ // SDK can create new session files. Without this, mkdirSync creates
1001
+ // root-owned directories and the SDK silently fails to save sessions.
1002
+ if (!fs.existsSync(dstDir)) {
938
1003
  fs.mkdirSync(dstDir, { recursive: true });
1004
+ if (uid != null) {
1005
+ try { require("child_process").execSync("chown -R " + uid + " " + JSON.stringify(dstDir)); } catch (e2) {}
1006
+ }
1007
+ } else {
1008
+ // Fix ownership of existing directories created by root
1009
+ try {
1010
+ var dirStat = fs.statSync(dstDir);
1011
+ if (uid != null && dirStat.uid !== uid) {
1012
+ require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstDir));
1013
+ }
1014
+ } catch (e2) {}
1015
+ }
1016
+ if (fs.existsSync(srcFile) && !fs.existsSync(dstFile)) {
939
1017
  fs.copyFileSync(srcFile, dstFile);
940
- var uid = osUsersMod.getLinuxUserUid(linuxUser);
941
1018
  if (uid != null) {
942
1019
  try { require("child_process").execSync("chown " + uid + " " + JSON.stringify(dstFile)); } catch (e2) {}
943
1020
  }
@@ -949,17 +1026,31 @@ function createSDKBridge(opts) {
949
1026
  }
950
1027
  }
951
1028
 
1029
+ // Reuse existing worker if alive, otherwise spawn a new one.
1030
+ // Keeping the worker alive between queries lets the SDK maintain session
1031
+ // state in memory, avoiding disk-based resume that fails on abort.
952
1032
  var worker;
953
- try {
954
- worker = spawnWorker(linuxUser);
955
- session.worker = worker;
956
- } catch (e) {
957
- session.isProcessing = false;
958
- onProcessingChanged();
959
- sendAndRecord(session, { type: "error", text: "Failed to spawn worker for " + linuxUser + ": " + (e.message || e) });
960
- sendAndRecord(session, { type: "done", code: 1 });
961
- sm.broadcastSessionList();
962
- return;
1033
+ var reusingWorker = false;
1034
+ if (session.worker && session.worker.ready && session.worker.process && !session.worker.process.killed) {
1035
+ worker = session.worker;
1036
+ reusingWorker = true;
1037
+ // Clear old message handlers so they don't fire for the new query
1038
+ worker.messageHandlers = [];
1039
+ worker._queryEnded = false;
1040
+ worker._abortSent = false;
1041
+ console.log("[sdk-bridge] Reusing existing worker pid=" + (worker.process ? worker.process.pid : "?"));
1042
+ } else {
1043
+ try {
1044
+ worker = spawnWorker(linuxUser);
1045
+ session.worker = worker;
1046
+ } catch (e) {
1047
+ session.isProcessing = false;
1048
+ onProcessingChanged();
1049
+ sendAndRecord(session, { type: "error", text: "Failed to spawn worker for " + linuxUser + ": " + (e.message || e) });
1050
+ sendAndRecord(session, { type: "done", code: 1 });
1051
+ sm.broadcastSessionList();
1052
+ return;
1053
+ }
963
1054
  }
964
1055
 
965
1056
  session.messageQueue = "worker"; // sentinel: messages go via worker IPC
@@ -969,7 +1060,7 @@ function createSDKBridge(opts) {
969
1060
  session.pendingElicitations = {};
970
1061
  session.streamedText = false;
971
1062
  session.responsePreview = "";
972
- session.abortController = { abort: function() { worker._abortSent = true; worker.send({ type: "abort" }); } };
1063
+ session.abortController = { abort: function() { console.log("[sdk-bridge] ABORT sent to worker pid=" + (worker.process ? worker.process.pid : "?")); worker._abortSent = true; worker.send({ type: "abort" }); } };
973
1064
 
974
1065
  // Build initial user message content
975
1066
  var content = [];
@@ -1076,6 +1167,7 @@ function createSDKBridge(opts) {
1076
1167
  break;
1077
1168
 
1078
1169
  case "query_done":
1170
+ console.log("[sdk-bridge] IPC query_done received, pid=" + (worker.process ? worker.process.pid : "?"));
1079
1171
  // Mark that we received a proper IPC completion, so the exit
1080
1172
  // handler fallback knows not to double-process.
1081
1173
  worker._queryEnded = true;
@@ -1096,6 +1188,7 @@ function createSDKBridge(opts) {
1096
1188
  break;
1097
1189
 
1098
1190
  case "query_error": {
1191
+ console.log("[sdk-bridge] IPC query_error received, pid=" + (worker.process ? worker.process.pid : "?") + " _fallback=" + !!msg._fallback + " _queryEnded=" + worker._queryEnded + " error=" + (msg.error || "").substring(0, 100));
1099
1192
  // Skip fallback errors from exit handler if we already handled the real one
1100
1193
  if (msg._fallback && worker._queryEnded) break;
1101
1194
  // Mark that we received a proper IPC completion
@@ -1105,8 +1198,8 @@ function createSDKBridge(opts) {
1105
1198
  // Only match the exact SDK error, not generic worker stderr
1106
1199
  var isSessionNotFound = qerrLower.indexOf("no conversation found with session id") !== -1;
1107
1200
  if (isSessionNotFound) {
1108
- // Pre-copy should have handled this. Clear stale cliSessionId
1109
- // so next message starts a fresh conversation in the same UI session.
1201
+ // Clear stale cliSessionId so next message starts a fresh
1202
+ // conversation in the same UI session.
1110
1203
  session.cliSessionId = null;
1111
1204
  }
1112
1205
  if (session.isProcessing) {
@@ -1218,16 +1311,18 @@ function createSDKBridge(opts) {
1218
1311
  });
1219
1312
 
1220
1313
  // Wait for worker to be ready, then send query
1221
- try {
1222
- await worker.readyPromise;
1223
- } catch (e) {
1224
- session.isProcessing = false;
1225
- onProcessingChanged();
1226
- sendAndRecord(session, { type: "error", text: "Worker failed to connect: " + (e.message || e) });
1227
- sendAndRecord(session, { type: "done", code: 1 });
1228
- sm.broadcastSessionList();
1229
- cleanupSessionWorker(session);
1230
- return;
1314
+ if (!reusingWorker) {
1315
+ try {
1316
+ await worker.readyPromise;
1317
+ } catch (e) {
1318
+ session.isProcessing = false;
1319
+ onProcessingChanged();
1320
+ sendAndRecord(session, { type: "error", text: "Worker failed to connect: " + (e.message || e) });
1321
+ sendAndRecord(session, { type: "done", code: 1 });
1322
+ sm.broadcastSessionList();
1323
+ killSessionWorker(session);
1324
+ return;
1325
+ }
1231
1326
  }
1232
1327
 
1233
1328
  worker.send({
@@ -1241,10 +1336,15 @@ function createSDKBridge(opts) {
1241
1336
  }
1242
1337
 
1243
1338
  function cleanupSessionWorker(session, fromWorker) {
1339
+ console.log("[sdk-bridge] cleanupSessionWorker() called, localId=" + session.localId +
1340
+ " fromWorkerPid=" + (fromWorker && fromWorker.process ? fromWorker.process.pid : "none") +
1341
+ " currentWorkerPid=" + (session.worker && session.worker.process ? session.worker.process.pid : "none") +
1342
+ " stack=" + new Error().stack.split("\n").slice(1, 4).join(" | "));
1244
1343
  // If called from a specific worker's exit/error handler, only cleanup if
1245
1344
  // that worker is still the session's current worker. Prevents stale
1246
1345
  // worker exit events from killing a newer worker.
1247
1346
  if (fromWorker && session.worker && session.worker !== fromWorker) {
1347
+ console.log("[sdk-bridge] cleanupSessionWorker: stale worker guard triggered, skipping");
1248
1348
  return;
1249
1349
  }
1250
1350
  session.queryInstance = null;
@@ -1254,7 +1354,18 @@ function createSDKBridge(opts) {
1254
1354
  session.pendingPermissions = {};
1255
1355
  session.pendingAskUser = {};
1256
1356
  session.pendingElicitations = {};
1357
+ // Keep the worker alive between queries so the SDK can maintain session
1358
+ // state in memory. Killing the worker after each query forces resume from
1359
+ // disk, but the SDK may not save the session file on abort, causing
1360
+ // "no conversation found" and losing all conversation history.
1361
+ // The worker is only killed when the UI session is destroyed or on error.
1362
+ }
1363
+
1364
+ // Force-kill the worker and remove it from the session.
1365
+ // Used when the session is destroyed or on unrecoverable errors.
1366
+ function killSessionWorker(session) {
1257
1367
  if (session.worker) {
1368
+ session._workerExitPromise = session.worker.exitPromise;
1258
1369
  session.worker.kill();
1259
1370
  session.worker = null;
1260
1371
  }
package/lib/sdk-worker.js CHANGED
@@ -86,6 +86,7 @@ function sendToDaemon(msg) {
86
86
  }
87
87
 
88
88
  function handleMessage(msg) {
89
+ try { require("fs").writeSync(2, "[sdk-worker] MSG: " + msg.type + "\n"); } catch (e) {}
89
90
  switch (msg.type) {
90
91
  case "query_start":
91
92
  handleQueryStart(msg);
package/lib/sessions.js CHANGED
@@ -427,6 +427,10 @@ function createSessionManager(opts) {
427
427
  if (session.messageQueue) {
428
428
  try { session.messageQueue.end(); } catch(e) {}
429
429
  }
430
+ if (session.worker) {
431
+ try { session.worker.kill(); } catch(e) {}
432
+ session.worker = null;
433
+ }
430
434
 
431
435
  if (session.cliSessionId) {
432
436
  try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
@@ -457,6 +461,10 @@ function createSessionManager(opts) {
457
461
  if (session.messageQueue) {
458
462
  try { session.messageQueue.end(); } catch(e) {}
459
463
  }
464
+ if (session.worker) {
465
+ try { session.worker.kill(); } catch(e) {}
466
+ session.worker = null;
467
+ }
460
468
  if (session.cliSessionId) {
461
469
  try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
462
470
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.23.1-beta.1",
3
+ "version": "2.23.1",
4
4
  "description": "Self-hosted Claude Code in your browser. Multi-session, multi-user, push notifications.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",