clay-server 2.26.0-beta.1 → 2.26.0-beta.3

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.
package/bin/cli.js CHANGED
@@ -1507,6 +1507,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1507
1507
  if (prevProjectMap[cwd]) {
1508
1508
  if (prevProjectMap[cwd].visibility) cwdEntry.visibility = prevProjectMap[cwd].visibility;
1509
1509
  if (prevProjectMap[cwd].allowedUsers) cwdEntry.allowedUsers = prevProjectMap[cwd].allowedUsers;
1510
+ if (prevProjectMap[cwd].ownerId) cwdEntry.ownerId = prevProjectMap[cwd].ownerId;
1510
1511
  }
1511
1512
  allProjects.push(cwdEntry);
1512
1513
  usedSlugs.push(slug);
@@ -1525,6 +1526,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1525
1526
  if (prevProjectMap[rp.path]) {
1526
1527
  if (prevProjectMap[rp.path].visibility) rpEntry.visibility = prevProjectMap[rp.path].visibility;
1527
1528
  if (prevProjectMap[rp.path].allowedUsers) rpEntry.allowedUsers = prevProjectMap[rp.path].allowedUsers;
1529
+ if (prevProjectMap[rp.path].ownerId) rpEntry.ownerId = prevProjectMap[rp.path].ownerId;
1528
1530
  }
1529
1531
  allProjects.push(rpEntry);
1530
1532
  }
@@ -1879,19 +1881,13 @@ async function restartDaemonWithTLS(config, callback) {
1879
1881
  }
1880
1882
  clearStaleConfig();
1881
1883
 
1882
- // Re-fork with TLS
1883
- var newConfig = {
1884
+ // Re-fork with TLS (preserve all existing config fields)
1885
+ var newConfig = Object.assign({}, config, {
1884
1886
  pid: null,
1885
- port: config.port,
1886
- pinHash: config.pinHash || null,
1887
1887
  tls: true,
1888
1888
  builtinCert: hasBuiltinCert,
1889
1889
  mkcertDetected: mkcertDetected,
1890
- debug: config.debug || false,
1891
- keepAwake: config.keepAwake || false,
1892
- dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
1893
- projects: config.projects || [],
1894
- };
1890
+ });
1895
1891
 
1896
1892
  ensureConfigDir();
1897
1893
  saveConfig(newConfig);
package/lib/os-users.js CHANGED
@@ -254,6 +254,26 @@ function toLinuxUsername(clayUsername) {
254
254
  return name;
255
255
  }
256
256
 
257
+ /**
258
+ * Ensure linger is enabled for a Linux user so systemd creates /run/user/<uid>.
259
+ * Required for CLI tools like gcloud and gh that need XDG_RUNTIME_DIR.
260
+ */
261
+ function ensureLinger(username) {
262
+ try {
263
+ var uid = execSync("id -u " + username, { encoding: "utf8", timeout: 5000, stdio: "pipe" }).trim();
264
+ var lingerFile = "/var/lib/systemd/linger/" + username;
265
+ if (fs.existsSync(lingerFile)) return;
266
+ execSync("loginctl enable-linger " + username, {
267
+ encoding: "utf8",
268
+ timeout: 10000,
269
+ stdio: "pipe",
270
+ });
271
+ console.log("[os-users] Enabled linger for " + username + " (uid " + uid + ")");
272
+ } catch (e) {
273
+ console.warn("[os-users] Failed to enable linger for " + username + ": " + (e.stderr || e.message || "").trim());
274
+ }
275
+ }
276
+
257
277
  /**
258
278
  * Check if a Linux user already exists.
259
279
  */
@@ -353,6 +373,7 @@ function provisionLinuxUser(clayUsername) {
353
373
  timeout: 15000,
354
374
  stdio: "pipe",
355
375
  });
376
+ ensureLinger(linuxName);
356
377
  console.log("[os-users] Provisioned Linux user: " + linuxName + " (Clay user: " + clayUsername + ")");
357
378
  installClaudeCli(linuxName);
358
379
  return { ok: true, linuxUser: linuxName };
@@ -383,6 +404,8 @@ function provisionAllUsers(usersModule) {
383
404
  console.log("[os-users] Claude CLI missing for " + user.linuxUser + ", installing...");
384
405
  installClaudeCli(user.linuxUser);
385
406
  }
407
+ // Ensure linger is enabled for existing users
408
+ ensureLinger(user.linuxUser);
386
409
  result.skipped.push({ id: user.id, username: user.username, linuxUser: user.linuxUser });
387
410
  continue;
388
411
  }
package/lib/project.js CHANGED
@@ -2204,6 +2204,8 @@ function createProjectContext(opts) {
2204
2204
  if (msg.type === "rewind_preview") {
2205
2205
  var session = getSessionForWs(ws);
2206
2206
  if (!session || !session.cliSessionId || !msg.uuid) return;
2207
+ // Reject preview requests while a rewind is executing
2208
+ if (session._rewindInProgress) return;
2207
2209
 
2208
2210
  (async function () {
2209
2211
  var result;
@@ -2233,6 +2235,12 @@ function createProjectContext(opts) {
2233
2235
  if (msg.type === "rewind_execute") {
2234
2236
  var session = getSessionForWs(ws);
2235
2237
  if (!session || !session.cliSessionId || !msg.uuid) return;
2238
+ // Guard against concurrent rewind executions
2239
+ if (session._rewindInProgress) {
2240
+ sendTo(ws, { type: "rewind_error", text: "Rewind already in progress." });
2241
+ return;
2242
+ }
2243
+ session._rewindInProgress = true;
2236
2244
  var mode = msg.mode || "both";
2237
2245
 
2238
2246
  (async function () {
@@ -2293,6 +2301,7 @@ function createProjectContext(opts) {
2293
2301
  } catch (err) {
2294
2302
  sendTo(ws, { type: "rewind_error", text: "Rewind failed: " + err.message });
2295
2303
  } finally {
2304
+ session._rewindInProgress = false;
2296
2305
  if (result && result.isTemp) result.cleanup();
2297
2306
  }
2298
2307
  })();
@@ -3328,7 +3337,7 @@ function createProjectContext(opts) {
3328
3337
  return;
3329
3338
  }
3330
3339
  }
3331
- var t = tm.create(msg.cols || 80, msg.rows || 24, getOsUserInfoForWs(ws));
3340
+ var t = tm.create(msg.cols || 80, msg.rows || 24, getOsUserInfoForWs(ws), ws);
3332
3341
  if (!t) {
3333
3342
  sendTo(ws, { type: "term_error", error: "Cannot create terminal (node-pty not available or limit reached)" });
3334
3343
  return;
@@ -3356,7 +3365,7 @@ function createProjectContext(opts) {
3356
3365
 
3357
3366
  if (msg.type === "term_resize") {
3358
3367
  if (msg.id && msg.cols > 0 && msg.rows > 0) {
3359
- tm.resize(msg.id, msg.cols, msg.rows);
3368
+ tm.resize(msg.id, msg.cols, msg.rows, ws);
3360
3369
  }
3361
3370
  return;
3362
3371
  }
package/lib/public/app.js CHANGED
@@ -6,15 +6,15 @@ import { initSidebar, renderSessionList, handleSearchResults, updateSessionPrese
6
6
  import { initMateSidebar, showMateSidebar, hideMateSidebar, renderMateSessionList, updateMateSidebarProfile, handleMateSearchResults } from './modules/mate-sidebar.js';
7
7
  import { initMateKnowledge, requestKnowledgeList, renderKnowledgeList, handleKnowledgeContent, hideKnowledge } from './modules/mate-knowledge.js';
8
8
  import { initMateMemory, renderMemoryList, hideMemory } from './modules/mate-memory.js';
9
- import { initRewind, setRewindMode, showRewindModal, clearPendingRewindUuid, addRewindButton } from './modules/rewind.js';
9
+ import { initRewind, setRewindMode, showRewindModal, clearPendingRewindUuid, addRewindButton, onRewindComplete, onRewindError } from './modules/rewind.js';
10
10
  import { initNotifications, showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './modules/notifications.js';
11
11
  import { initInput, clearPendingImages, handleInputSync, autoResize, builtinCommands, sendMessage, hasSendableContent, setScheduleBtnDisabled, setScheduleDelayMs, clearScheduleDelay } from './modules/input.js';
12
12
  import { initQrCode, triggerShare } from './modules/qrcode.js';
13
13
  import { initFileBrowser, loadRootDirectory, refreshTree, handleFsList, handleFsRead, handleDirChanged, refreshIfOpen, handleFileChanged, handleFileHistory, handleGitDiff, handleFileAt, getPendingNavigate, closeFileViewer, resetFileBrowser } from './modules/filebrowser.js';
14
- import { initTerminal, openTerminal, closeTerminal, resetTerminals, handleTermList, handleTermCreated, handleTermOutput, handleTermExited, handleTermClosed, sendTerminalCommand } from './modules/terminal.js';
14
+ import { initTerminal, openTerminal, closeTerminal, resetTerminals, handleTermList, handleTermCreated, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed, sendTerminalCommand } from './modules/terminal.js';
15
15
  import { initStickyNotes, handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, openArchive, closeArchive, isArchiveOpen, hideNotes, showNotes, isNotesVisible } from './modules/sticky-notes.js';
16
16
  import { initTheme, getThemeColor, getComputedVar, onThemeChange, getCurrentTheme } from './modules/theme.js';
17
- import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionResolved, markPermissionCancelled, renderElicitationRequest, markElicitationResolved, renderPlanBanner, renderPlanCard, handleTodoWrite, handleTaskCreate, handleTaskUpdate, startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, addTurnMeta, enableMainInput, getTools, getPlanContent, setPlanContent, isPlanFilePath, getTodoTools, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, updateSubagentProgress, initSubagentStop, closeToolGroup, removeToolFromGroup } from './modules/tools.js';
17
+ import { initTools, resetToolState, saveToolState, restoreToolState, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionResolved, markPermissionCancelled, renderElicitationRequest, markElicitationResolved, renderPlanBanner, renderPlanCard, handleTodoWrite, handleTaskCreate, handleTaskUpdate, startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, addTurnMeta, resetTurnMetaCost, enableMainInput, getTools, getPlanContent, setPlanContent, isPlanFilePath, getTodoTools, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, updateSubagentProgress, initSubagentStop, closeToolGroup, removeToolFromGroup } from './modules/tools.js';
18
18
  import { initServerSettings, updateSettingsStats, updateSettingsModels, updateDaemonConfig, handleSetPinResult, handleKeepAwakeChanged, handleAutoContinueChanged, handleRestartResult, handleShutdownResult, handleSharedEnv, handleSharedEnvSaved, handleGlobalClaudeMdRead, handleGlobalClaudeMdWrite } from './modules/server-settings.js';
19
19
  import { initProjectSettings, handleInstructionsRead, handleInstructionsWrite, handleProjectEnv, handleProjectEnvSaved, isProjectSettingsOpen, handleProjectSharedEnv, handleProjectSharedEnvSaved, handleProjectOwnerChanged } from './modules/project-settings.js';
20
20
  import { initSkills, handleSkillInstalled, handleSkillUninstalled } from './modules/skills.js';
@@ -2468,7 +2468,9 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
2468
2468
  }
2469
2469
 
2470
2470
  function accumulateUsage(cost, usage) {
2471
- if (cost != null) sessionUsage.cost += cost;
2471
+ // cost is the SDK's total_cost_usd — a cumulative running total, not a delta.
2472
+ // Assign directly instead of summing to avoid overcounting.
2473
+ if (cost != null) sessionUsage.cost = cost;
2472
2474
  if (usage) {
2473
2475
  sessionUsage.input += usage.input_tokens || usage.inputTokens || 0;
2474
2476
  sessionUsage.output += usage.output_tokens || usage.outputTokens || 0;
@@ -2666,7 +2668,8 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
2666
2668
  }
2667
2669
 
2668
2670
  function accumulateContext(cost, usage, modelUsage, lastStreamInputTokens) {
2669
- if (cost != null) contextData.cost += cost;
2671
+ // cost is the SDK's total_cost_usd — a cumulative running total, not a delta.
2672
+ if (cost != null) contextData.cost = cost;
2670
2673
  // Use latest turn values (not cumulative) since each turn's input_tokens
2671
2674
  // already includes the full conversation context up to that point
2672
2675
  if (usage) {
@@ -3718,6 +3721,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
3718
3721
  setStatus("connected");
3719
3722
  if (!loopActive) enableMainInput();
3720
3723
  resetUsage();
3724
+ resetTurnMetaCost();
3721
3725
  resetContext();
3722
3726
  // Clear header indicators
3723
3727
  clearRateLimitIndicator();
@@ -4711,6 +4715,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4711
4715
  break;
4712
4716
 
4713
4717
  case "rewind_complete":
4718
+ onRewindComplete();
4714
4719
  setRewindMode(false);
4715
4720
  var rewindText = "Rewound to earlier point. Files have been restored.";
4716
4721
  if (msg.mode === "chat") rewindText = "Conversation rewound to earlier point.";
@@ -4719,6 +4724,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4719
4724
  break;
4720
4725
 
4721
4726
  case "rewind_error":
4727
+ onRewindError();
4722
4728
  clearPendingRewindUuid();
4723
4729
  addSystemMessage(msg.text || "Rewind failed.", true);
4724
4730
  break;
@@ -4809,6 +4815,10 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4809
4815
  handleTermOutput(msg);
4810
4816
  break;
4811
4817
 
4818
+ case "term_resized":
4819
+ handleTermResized(msg);
4820
+ break;
4821
+
4812
4822
  case "term_exited":
4813
4823
  handleTermExited(msg);
4814
4824
  break;
@@ -4976,6 +4986,22 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4976
4986
  // Update mate sidebar if currently viewing this mate
4977
4987
  if (dmMode && dmTargetUser && dmTargetUser.isMate && dmTargetUser.id === msg.mate.id) {
4978
4988
  updateMateSidebarProfile(msg.mate);
4989
+ // Sync dmTargetUser so subsequent renders use fresh data
4990
+ var mp2 = msg.mate.profile || {};
4991
+ dmTargetUser.displayName = mp2.displayName || msg.mate.name || dmTargetUser.displayName;
4992
+ dmTargetUser.avatarStyle = mp2.avatarStyle || dmTargetUser.avatarStyle;
4993
+ dmTargetUser.avatarSeed = mp2.avatarSeed || dmTargetUser.avatarSeed;
4994
+ dmTargetUser.avatarColor = mp2.avatarColor || dmTargetUser.avatarColor;
4995
+ dmTargetUser.avatarCustom = mp2.avatarCustom || "";
4996
+ dmTargetUser.profile = mp2;
4997
+ // Refresh body dataset so new chat bubbles use the updated avatar
4998
+ document.body.dataset.mateAvatarUrl = mateAvatarUrl(dmTargetUser, 36);
4999
+ document.body.dataset.mateName = mp2.displayName || msg.mate.name || "";
5000
+ // Update existing chat bubble avatars
5001
+ var mateAvis = document.querySelectorAll(".dm-bubble-avatar-mate");
5002
+ for (var mbi = 0; mbi < mateAvis.length; mbi++) {
5003
+ mateAvis[mbi].src = document.body.dataset.mateAvatarUrl;
5004
+ }
4979
5005
  }
4980
5006
  // Update DM header if currently chatting with this mate
4981
5007
  if (dmMode && dmTargetUser && dmTargetUser.id === msg.mate.id) {
@@ -4,6 +4,8 @@ import { iconHtml, refreshIcons } from './icons.js';
4
4
  var ctx;
5
5
  var rewindMode = false;
6
6
  var pendingRewindUuid = null;
7
+ var rewindPreviewInFlight = false;
8
+ var rewindExecuting = false;
7
9
  var rewindBannerEl = null;
8
10
  var rewindScrollHandler = null;
9
11
  var rewindModal, rewindSummary, rewindFilesList, rewindConfirmBtn, rewindCancelBtn, rewindModeOptions;
@@ -50,6 +52,17 @@ export function clearPendingRewindUuid() {
50
52
  pendingRewindUuid = null;
51
53
  }
52
54
 
55
+ export function onRewindComplete() {
56
+ rewindExecuting = false;
57
+ rewindPreviewInFlight = false;
58
+ pendingRewindUuid = null;
59
+ }
60
+
61
+ export function onRewindError() {
62
+ rewindExecuting = false;
63
+ rewindPreviewInFlight = false;
64
+ }
65
+
53
66
  function initiateRewind(uuid) {
54
67
  if (ctx.processing) {
55
68
  ctx.addSystemMessage("Cannot rewind while processing. Stop the current operation first.", true);
@@ -59,7 +72,16 @@ function initiateRewind(uuid) {
59
72
  ctx.addSystemMessage("No rewind point found for this turn.", true);
60
73
  return;
61
74
  }
75
+ if (rewindPreviewInFlight) {
76
+ // Debounce: ignore clicks while a preview is already in flight
77
+ return;
78
+ }
79
+ if (rewindExecuting) {
80
+ ctx.addSystemMessage("Rewind already in progress.", true);
81
+ return;
82
+ }
62
83
  pendingRewindUuid = uuid;
84
+ rewindPreviewInFlight = true;
63
85
  if (ctx.ws && ctx.connected) {
64
86
  ctx.ws.send(JSON.stringify({ type: "rewind_preview", uuid: uuid }));
65
87
  }
@@ -98,6 +120,17 @@ function updateSummaryForMode() {
98
120
  }
99
121
 
100
122
  export function showRewindModal(data) {
123
+ rewindPreviewInFlight = false;
124
+
125
+ // Ignore stale preview results that don't match current pending UUID
126
+ if (data.uuid && pendingRewindUuid && data.uuid !== pendingRewindUuid) {
127
+ return;
128
+ }
129
+ // Ignore if a rewind is already executing
130
+ if (rewindExecuting) {
131
+ return;
132
+ }
133
+
101
134
  var p = data.preview || data;
102
135
  var filePaths = p.filesChanged || p.filePaths || p.files || [];
103
136
  var fileCount = filePaths.length;
@@ -171,6 +204,7 @@ export function showRewindModal(data) {
171
204
  export function hideRewindModal() {
172
205
  rewindModal.classList.add("hidden");
173
206
  pendingRewindUuid = null;
207
+ rewindPreviewInFlight = false;
174
208
  }
175
209
 
176
210
  export function renderDiffPre(text) {
@@ -333,7 +367,9 @@ export function initRewind(_ctx) {
333
367
  });
334
368
 
335
369
  rewindConfirmBtn.addEventListener("click", function() {
370
+ if (rewindExecuting) return;
336
371
  if (pendingRewindUuid && ctx.ws && ctx.connected) {
372
+ rewindExecuting = true;
337
373
  var mode = getSelectedMode();
338
374
  ctx.ws.send(JSON.stringify({ type: "rewind_execute", uuid: pendingRewindUuid, mode: mode }));
339
375
  }
@@ -653,6 +653,14 @@ export function handleTermOutput(msg) {
653
653
  }
654
654
  }
655
655
 
656
+ export function handleTermResized(msg) {
657
+ if (!msg.id) return;
658
+ var tab = tabs.get(msg.id);
659
+ if (tab && tab.xterm && msg.cols > 0 && msg.rows > 0) {
660
+ tab.xterm.resize(msg.cols, msg.rows);
661
+ }
662
+ }
663
+
656
664
  export function handleTermExited(msg) {
657
665
  if (!msg.id) return;
658
666
  var tab = tabs.get(msg.id);
@@ -2063,13 +2063,29 @@ export function markSubagentDone(parentToolId, status, summary, usage) {
2063
2063
  if (usage) updateSubagentProgress(parentToolId, usage, null);
2064
2064
  }
2065
2065
 
2066
+ var _lastCumulativeCost = 0;
2067
+
2068
+ export function resetTurnMetaCost() {
2069
+ _lastCumulativeCost = 0;
2070
+ }
2071
+
2066
2072
  export function addTurnMeta(cost, duration) {
2067
2073
  closeToolGroup();
2068
2074
  var div = document.createElement("div");
2069
2075
  div.className = "turn-meta";
2070
2076
  div.dataset.turn = ctx.turnCounter;
2071
2077
  var parts = [];
2072
- if (cost != null) parts.push("$" + cost.toFixed(4));
2078
+ if (cost != null) {
2079
+ // cost is cumulative total_cost_usd from the SDK.
2080
+ // When the SDK session restarts, total_cost_usd resets to 0 so cost
2081
+ // can drop below _lastCumulativeCost. In that case the entire cost
2082
+ // value IS the delta for this turn (fresh SDK session).
2083
+ var delta = cost - _lastCumulativeCost;
2084
+ if (delta < 0) delta = cost;
2085
+ _lastCumulativeCost = cost;
2086
+ var deltaStr = delta > 0 ? "+$" + delta.toFixed(4) : "$0.0000";
2087
+ parts.push(deltaStr + " \u2192 $" + cost.toFixed(4));
2088
+ }
2073
2089
  if (duration != null) parts.push((duration / 1000).toFixed(1) + "s");
2074
2090
  if (parts.length) {
2075
2091
  div.textContent = parts.join(" \u00b7 ");
@@ -2114,6 +2130,7 @@ export function saveToolState() {
2114
2130
  currentToolGroup: currentToolGroup,
2115
2131
  toolGroupCounter: toolGroupCounter,
2116
2132
  toolGroups: toolGroups,
2133
+ lastCumulativeCost: _lastCumulativeCost,
2117
2134
  };
2118
2135
  }
2119
2136
 
@@ -2126,6 +2143,7 @@ export function restoreToolState(saved) {
2126
2143
  currentToolGroup = saved.currentToolGroup;
2127
2144
  toolGroupCounter = saved.toolGroupCounter;
2128
2145
  toolGroups = saved.toolGroups;
2146
+ _lastCumulativeCost = saved.lastCumulativeCost || 0;
2129
2147
  if (todoWidgetEl) {
2130
2148
  setupTodoObserver();
2131
2149
  }
@@ -2146,6 +2164,9 @@ export function resetToolState() {
2146
2164
  currentToolGroup = null;
2147
2165
  toolGroupCounter = 0;
2148
2166
  toolGroups = {};
2167
+ // NOTE: do NOT reset _lastCumulativeCost here — it must persist across
2168
+ // turns so addTurnMeta can compute per-turn deltas. It is only cleared
2169
+ // on new conversation via resetTurnMetaCost().
2149
2170
  var stickyEl = document.getElementById("todo-sticky");
2150
2171
  if (stickyEl) { stickyEl.classList.add("hidden"); stickyEl.innerHTML = ""; }
2151
2172
  }
package/lib/sdk-bridge.js CHANGED
@@ -1138,6 +1138,8 @@ function createSDKBridge(opts) {
1138
1138
  if (session.lastRewindUuid) {
1139
1139
  queryOptions.resumeSessionAt = session.lastRewindUuid;
1140
1140
  delete session.lastRewindUuid;
1141
+ // Persist the deletion so server restarts don't re-use a stale UUID
1142
+ sm.saveSessionFile(session);
1141
1143
  }
1142
1144
  }
1143
1145
 
@@ -1687,8 +1689,13 @@ function createSDKBridge(opts) {
1687
1689
  }
1688
1690
 
1689
1691
  async function processQueryStream(session) {
1692
+ // Capture references at start so we only clean up OUR resources in finally,
1693
+ // not resources from a newer query that may have been created after an abort.
1694
+ var myQueryInstance = session.queryInstance;
1695
+ var myMessageQueue = session.messageQueue;
1696
+ var myAbortController = session.abortController;
1690
1697
  try {
1691
- for await (var msg of session.queryInstance) {
1698
+ for await (var msg of myQueryInstance) {
1692
1699
  processSDKMessage(session, msg);
1693
1700
  }
1694
1701
  // Stream ended normally after a task stop — no "result" message was sent,
@@ -1704,7 +1711,7 @@ function createSDKBridge(opts) {
1704
1711
  if (session.isProcessing) {
1705
1712
  session.isProcessing = false;
1706
1713
  onProcessingChanged();
1707
- if (err.name === "AbortError" || (session.abortController && session.abortController.signal.aborted) || session.taskStopRequested) {
1714
+ if (err.name === "AbortError" || (myAbortController && myAbortController.signal.aborted) || session.taskStopRequested) {
1708
1715
  if (!session.destroying) {
1709
1716
  sendAndRecord(session, { type: "info", text: "Interrupted \u00b7 What should Claude do instead?" });
1710
1717
  sendAndRecord(session, { type: "done", code: 0 });
@@ -1777,16 +1784,19 @@ function createSDKBridge(opts) {
1777
1784
  } finally {
1778
1785
  // Close the SDK query to terminate the underlying claude child process.
1779
1786
  // Without this, the process stays alive indefinitely (single-user mode).
1780
- if (session.queryInstance) {
1787
+ // Only clean up if the session still references OUR resources.
1788
+ // A rewind + new startQuery may have already replaced these with
1789
+ // a newer query — clobbering them would kill the new query.
1790
+ if (session.queryInstance === myQueryInstance) {
1781
1791
  try {
1782
1792
  if (typeof session.queryInstance.close === "function") {
1783
1793
  session.queryInstance.close();
1784
1794
  }
1785
1795
  } catch (e) {}
1796
+ session.queryInstance = null;
1786
1797
  }
1787
- session.queryInstance = null;
1788
- session.messageQueue = null;
1789
- session.abortController = null;
1798
+ if (session.messageQueue === myMessageQueue) session.messageQueue = null;
1799
+ if (session.abortController === myAbortController) session.abortController = null;
1790
1800
  session.taskStopRequested = false;
1791
1801
  session.pendingPermissions = {};
1792
1802
  session.pendingAskUser = {};
@@ -1952,6 +1962,8 @@ function createSDKBridge(opts) {
1952
1962
  if (session.lastRewindUuid) {
1953
1963
  queryOptions.resumeSessionAt = session.lastRewindUuid;
1954
1964
  delete session.lastRewindUuid;
1965
+ // Persist the deletion so server restarts don't re-use a stale UUID
1966
+ sm.saveSessionFile(session);
1955
1967
  }
1956
1968
  }
1957
1969
 
package/lib/sessions.js CHANGED
@@ -96,10 +96,14 @@ function createSessionManager(opts) {
96
96
  lines.push(JSON.stringify(session.history[i]));
97
97
  }
98
98
  var sfPath = sessionFilePath(session.cliSessionId);
99
- fs.writeFileSync(sfPath, lines.join("\n") + "\n");
99
+ // Atomic write: write to temp file then rename, so a crash mid-write
100
+ // cannot leave a truncated/corrupted session file.
101
+ var tmpPath = sfPath + ".tmp." + process.pid;
102
+ fs.writeFileSync(tmpPath, lines.join("\n") + "\n");
100
103
  if (process.platform !== "win32") {
101
- try { fs.chmodSync(sfPath, 0o600); } catch (chmodErr) {}
104
+ try { fs.chmodSync(tmpPath, 0o600); } catch (chmodErr) {}
102
105
  }
106
+ fs.renameSync(tmpPath, sfPath);
103
107
  } catch(e) {
104
108
  console.error("[session] Failed to save session file:", e.message);
105
109
  }
@@ -123,6 +127,13 @@ function createSessionManager(opts) {
123
127
  var files;
124
128
  try { files = fs.readdirSync(sessionsDir); } catch { return; }
125
129
 
130
+ // Clean up stale temp files from interrupted atomic writes
131
+ for (var ti = 0; ti < files.length; ti++) {
132
+ if (files[ti].indexOf(".tmp.") !== -1) {
133
+ try { fs.unlinkSync(path.join(sessionsDir, files[ti])); } catch (e) {}
134
+ }
135
+ }
136
+
126
137
  var loaded = [];
127
138
  for (var i = 0; i < files.length; i++) {
128
139
  if (!files[i].endsWith(".jsonl")) continue;
@@ -16,7 +16,7 @@ function createTerminalManager(opts) {
16
16
  var nextId = 1;
17
17
  var terminals = new Map(); // id -> terminal session
18
18
 
19
- function create(cols, rows, osUserInfo) {
19
+ function create(cols, rows, osUserInfo, ownerWs) {
20
20
  if (terminals.size >= MAX_TERMINALS) return null;
21
21
 
22
22
  var pty = createTerminal(cwd, cols, rows, osUserInfo);
@@ -34,6 +34,7 @@ function createTerminalManager(opts) {
34
34
  exited: false,
35
35
  exitCode: null,
36
36
  subscribers: new Set(),
37
+ ownerWs: ownerWs || null,
37
38
  };
38
39
 
39
40
  pty.onData(function (data) {
@@ -84,6 +85,11 @@ function createTerminalManager(opts) {
84
85
  sendTo(ws, { type: "term_output", id: id, data: replay });
85
86
  }
86
87
 
88
+ // Send current terminal dimensions so the client renders at the correct size
89
+ if (!alreadySubscribed && session.cols && session.rows) {
90
+ sendTo(ws, { type: "term_resized", id: id, cols: session.cols, rows: session.rows });
91
+ }
92
+
87
93
  // If already exited, notify
88
94
  if (session.exited) {
89
95
  sendTo(ws, { type: "term_exited", id: id });
@@ -111,14 +117,22 @@ function createTerminalManager(opts) {
111
117
  }
112
118
  }
113
119
 
114
- function resize(id, cols, rows) {
120
+ function resize(id, cols, rows, sourceWs) {
115
121
  var session = terminals.get(id);
116
122
  if (!session || !session.pty) return;
123
+ // Only the terminal owner can resize the PTY.
124
+ // Observers resizing would cause SIGWINCH and flood the owner with escape sequences.
125
+ if (session.ownerWs && sourceWs && sourceWs !== session.ownerWs) return;
117
126
  if (cols > 0 && rows > 0) {
118
127
  try {
119
128
  session.pty.resize(cols, rows);
120
129
  session.cols = cols;
121
130
  session.rows = rows;
131
+ // Notify other subscribers about the resize so their xterm stays in sync
132
+ var msg = JSON.stringify({ type: "term_resized", id: id, cols: cols, rows: rows });
133
+ for (var ws of session.subscribers) {
134
+ if (ws.readyState === 1 && ws !== sourceWs) ws.send(msg);
135
+ }
122
136
  } catch (e) {}
123
137
  }
124
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.26.0-beta.1",
3
+ "version": "2.26.0-beta.3",
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",