clay-server 2.26.0-beta.2 → 2.26.0-beta.4

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/lib/project.js CHANGED
@@ -18,6 +18,32 @@ var userPresence = require("./user-presence");
18
18
  var { attachDebate } = require("./project-debate");
19
19
  var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
20
20
 
21
+ // --- Context Sources persistence ---
22
+ var _ctxSrcConfig = require("./config");
23
+ var _ctxSrcDir = path.join(_ctxSrcConfig.CONFIG_DIR, "context-sources");
24
+
25
+ function loadContextSources(slug) {
26
+ try {
27
+ var filePath = path.join(_ctxSrcDir, slug + ".json");
28
+ var data = JSON.parse(fs.readFileSync(filePath, "utf8"));
29
+ return data.active || [];
30
+ } catch (e) {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ function saveContextSources(slug, activeIds) {
36
+ try {
37
+ if (!fs.existsSync(_ctxSrcDir)) {
38
+ fs.mkdirSync(_ctxSrcDir, { recursive: true });
39
+ }
40
+ var filePath = path.join(_ctxSrcDir, slug + ".json");
41
+ fs.writeFileSync(filePath, JSON.stringify({ active: activeIds }), "utf8");
42
+ } catch (e) {
43
+ console.error("[context-sources] Failed to save:", e.message);
44
+ }
45
+ }
46
+
21
47
  // Validate environment variable string (KEY=VALUE per line)
22
48
  // Returns null if valid, or an error string if invalid
23
49
  function validateEnvString(str) {
@@ -1235,6 +1261,7 @@ function createProjectContext(opts) {
1235
1261
  }
1236
1262
  sendTo(ws, { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 });
1237
1263
  sendTo(ws, { type: "term_list", terminals: tm.list() });
1264
+ sendTo(ws, { type: "context_sources_state", active: loadContextSources(slug) });
1238
1265
  sendTo(ws, { type: "notes_list", notes: nm.list() });
1239
1266
  sendTo(ws, { type: "loop_registry_updated", records: getHubSchedules() });
1240
1267
 
@@ -2204,6 +2231,8 @@ function createProjectContext(opts) {
2204
2231
  if (msg.type === "rewind_preview") {
2205
2232
  var session = getSessionForWs(ws);
2206
2233
  if (!session || !session.cliSessionId || !msg.uuid) return;
2234
+ // Reject preview requests while a rewind is executing
2235
+ if (session._rewindInProgress) return;
2207
2236
 
2208
2237
  (async function () {
2209
2238
  var result;
@@ -2233,6 +2262,12 @@ function createProjectContext(opts) {
2233
2262
  if (msg.type === "rewind_execute") {
2234
2263
  var session = getSessionForWs(ws);
2235
2264
  if (!session || !session.cliSessionId || !msg.uuid) return;
2265
+ // Guard against concurrent rewind executions
2266
+ if (session._rewindInProgress) {
2267
+ sendTo(ws, { type: "rewind_error", text: "Rewind already in progress." });
2268
+ return;
2269
+ }
2270
+ session._rewindInProgress = true;
2236
2271
  var mode = msg.mode || "both";
2237
2272
 
2238
2273
  (async function () {
@@ -2293,6 +2328,7 @@ function createProjectContext(opts) {
2293
2328
  } catch (err) {
2294
2329
  sendTo(ws, { type: "rewind_error", text: "Rewind failed: " + err.message });
2295
2330
  } finally {
2331
+ session._rewindInProgress = false;
2296
2332
  if (result && result.isTemp) result.cleanup();
2297
2333
  }
2298
2334
  })();
@@ -3365,6 +3401,14 @@ function createProjectContext(opts) {
3365
3401
  if (msg.id) {
3366
3402
  tm.close(msg.id);
3367
3403
  send({ type: "term_list", terminals: tm.list() });
3404
+ // Remove closed terminal from context sources
3405
+ var saved = loadContextSources(slug);
3406
+ var termKey = "term:" + msg.id;
3407
+ var filtered = saved.filter(function(id) { return id !== termKey; });
3408
+ if (filtered.length !== saved.length) {
3409
+ saveContextSources(slug, filtered);
3410
+ send({ type: "context_sources_state", active: filtered });
3411
+ }
3368
3412
  }
3369
3413
  return;
3370
3414
  }
@@ -3377,6 +3421,13 @@ function createProjectContext(opts) {
3377
3421
  return;
3378
3422
  }
3379
3423
 
3424
+ // --- Context Sources ---
3425
+ if (msg.type === "context_sources_save") {
3426
+ var activeIds = msg.active || [];
3427
+ saveContextSources(slug, activeIds);
3428
+ return;
3429
+ }
3430
+
3380
3431
  // --- Scheduled tasks permission gate ---
3381
3432
  if (msg.type === "loop_start" || msg.type === "loop_stop" || msg.type === "loop_registry_files" ||
3382
3433
  msg.type === "loop_registry_list" || msg.type === "loop_registry_update" || msg.type === "loop_registry_rename" ||
@@ -3835,6 +3886,91 @@ function createProjectContext(opts) {
3835
3886
  fullText = mentionPrefix + "\n\n" + fullText;
3836
3887
  }
3837
3888
 
3889
+ // Inject active terminal context sources (delta only: send new output since last message)
3890
+ var TERM_CONTEXT_MAX = 8192; // 8KB max per terminal per message
3891
+ var TERM_HEAD_SIZE = 2048; // keep first 2KB for error context
3892
+ var TERM_TAIL_SIZE = 6144; // keep last 6KB for recent state
3893
+ var ctxSources = loadContextSources(slug);
3894
+ if (ctxSources.length > 0) {
3895
+ if (!session._termContextCursors) session._termContextCursors = {};
3896
+ var termContextParts = [];
3897
+ for (var ci = 0; ci < ctxSources.length; ci++) {
3898
+ var srcId = ctxSources[ci];
3899
+ if (srcId.startsWith("term:")) {
3900
+ var termId = parseInt(srcId.split(":")[1], 10);
3901
+ var sb = tm.getScrollback(termId);
3902
+ if (sb) {
3903
+ var lastCursor;
3904
+ if (termId in session._termContextCursors) {
3905
+ lastCursor = session._termContextCursors[termId];
3906
+ // Terminal was recycled (closed and reopened with same ID) — reset cursor
3907
+ if (lastCursor > sb.totalBytesWritten) lastCursor = 0;
3908
+ } else {
3909
+ // First time seeing this terminal — include last 8KB (what user can see now)
3910
+ lastCursor = Math.max(0, sb.totalBytesWritten - TERM_CONTEXT_MAX);
3911
+ }
3912
+ var newBytes = sb.totalBytesWritten - lastCursor;
3913
+ session._termContextCursors[termId] = sb.totalBytesWritten;
3914
+ if (newBytes <= 0) continue;
3915
+ // Build timestamped delta from chunks
3916
+ var deltaChunks = [];
3917
+ var bytePos = sb.bufferStart;
3918
+ for (var chunkIdx = 0; chunkIdx < sb.chunks.length; chunkIdx++) {
3919
+ var chunk = sb.chunks[chunkIdx];
3920
+ var chunkEnd = bytePos + chunk.data.length;
3921
+ if (chunkEnd > lastCursor) {
3922
+ // This chunk has new content
3923
+ var chunkData = chunk.data;
3924
+ if (bytePos < lastCursor) {
3925
+ // Partial chunk: only the part after lastCursor
3926
+ chunkData = chunkData.slice(lastCursor - bytePos);
3927
+ }
3928
+ deltaChunks.push({ ts: chunk.ts, data: chunkData });
3929
+ }
3930
+ bytePos = chunkEnd;
3931
+ }
3932
+ if (deltaChunks.length === 0) continue;
3933
+ // Format with timestamps: group by second to avoid excessive timestamps
3934
+ var lines = [];
3935
+ var lastTimeSec = 0;
3936
+ for (var di = 0; di < deltaChunks.length; di++) {
3937
+ var dc = deltaChunks[di];
3938
+ var cleaned = dc.data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
3939
+ if (!cleaned) continue;
3940
+ var timeSec = Math.floor(dc.ts / 1000);
3941
+ if (timeSec !== lastTimeSec) {
3942
+ var d = new Date(dc.ts);
3943
+ var timeStr = d.toTimeString().slice(0, 8); // HH:MM:SS
3944
+ lines.push("[" + timeStr + "] " + cleaned);
3945
+ lastTimeSec = timeSec;
3946
+ } else {
3947
+ lines.push(cleaned);
3948
+ }
3949
+ }
3950
+ var delta = lines.join("").trim();
3951
+ if (!delta) continue;
3952
+ var termInfo = tm.list().find(function(t) { return t.id === termId; });
3953
+ var termTitle = termInfo ? termInfo.title : "Terminal " + termId;
3954
+ var header;
3955
+ if (delta.length > TERM_CONTEXT_MAX) {
3956
+ var head = delta.slice(0, TERM_HEAD_SIZE);
3957
+ var tail = delta.slice(-TERM_TAIL_SIZE);
3958
+ var omittedBytes = delta.length - TERM_HEAD_SIZE - TERM_TAIL_SIZE;
3959
+ var omittedLines = delta.slice(TERM_HEAD_SIZE, delta.length - TERM_TAIL_SIZE).split("\n").length;
3960
+ delta = head + "\n\n... (" + omittedLines + " lines / " + Math.round(omittedBytes / 1024) + "KB omitted) ...\n\n" + tail;
3961
+ header = "[New terminal output from " + termTitle + " (large output, head+tail shown)]";
3962
+ } else {
3963
+ header = "[New terminal output from " + termTitle + "]";
3964
+ }
3965
+ termContextParts.push(header + "\n```\n" + delta + "\n```");
3966
+ }
3967
+ }
3968
+ }
3969
+ if (termContextParts.length > 0) {
3970
+ fullText = termContextParts.join("\n\n") + "\n\n" + fullText;
3971
+ }
3972
+ }
3973
+
3838
3974
  if (!session.isProcessing) {
3839
3975
  session.isProcessing = true;
3840
3976
  onProcessingChanged();
package/lib/public/app.js CHANGED
@@ -6,15 +6,16 @@ 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
14
  import { initTerminal, openTerminal, closeTerminal, resetTerminals, handleTermList, handleTermCreated, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed, sendTerminalCommand } from './modules/terminal.js';
15
+ import { initContextSources, updateTerminalList, handleContextSourcesState, getActiveSources, hasActiveSources } from './modules/context-sources.js';
15
16
  import { initStickyNotes, handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, openArchive, closeArchive, isArchiveOpen, hideNotes, showNotes, isNotesVisible } from './modules/sticky-notes.js';
16
17
  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';
18
+ 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
19
  import { initServerSettings, updateSettingsStats, updateSettingsModels, updateDaemonConfig, handleSetPinResult, handleKeepAwakeChanged, handleAutoContinueChanged, handleRestartResult, handleShutdownResult, handleSharedEnv, handleSharedEnvSaved, handleGlobalClaudeMdRead, handleGlobalClaudeMdWrite } from './modules/server-settings.js';
19
20
  import { initProjectSettings, handleInstructionsRead, handleInstructionsWrite, handleProjectEnv, handleProjectEnvSaved, isProjectSettingsOpen, handleProjectSharedEnv, handleProjectSharedEnvSaved, handleProjectOwnerChanged } from './modules/project-settings.js';
20
21
  import { initSkills, handleSkillInstalled, handleSkillUninstalled } from './modules/skills.js';
@@ -2468,7 +2469,9 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
2468
2469
  }
2469
2470
 
2470
2471
  function accumulateUsage(cost, usage) {
2471
- if (cost != null) sessionUsage.cost += cost;
2472
+ // cost is the SDK's total_cost_usd — a cumulative running total, not a delta.
2473
+ // Assign directly instead of summing to avoid overcounting.
2474
+ if (cost != null) sessionUsage.cost = cost;
2472
2475
  if (usage) {
2473
2476
  sessionUsage.input += usage.input_tokens || usage.inputTokens || 0;
2474
2477
  sessionUsage.output += usage.output_tokens || usage.outputTokens || 0;
@@ -2666,7 +2669,8 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
2666
2669
  }
2667
2670
 
2668
2671
  function accumulateContext(cost, usage, modelUsage, lastStreamInputTokens) {
2669
- if (cost != null) contextData.cost += cost;
2672
+ // cost is the SDK's total_cost_usd — a cumulative running total, not a delta.
2673
+ if (cost != null) contextData.cost = cost;
2670
2674
  // Use latest turn values (not cumulative) since each turn's input_tokens
2671
2675
  // already includes the full conversation context up to that point
2672
2676
  if (usage) {
@@ -3660,23 +3664,13 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
3660
3664
  suggestionChipsEl.innerHTML = "";
3661
3665
  var chip = document.createElement("button");
3662
3666
  chip.className = "suggestion-chip";
3663
- chip.innerHTML =
3664
- '<span class="suggestion-chip-send">' + iconHtml("sparkles") +
3665
- '<span class="suggestion-chip-text">' + escapeHtml(suggestion) + '</span></span>' +
3666
- '<span class="suggestion-chip-edit">' + iconHtml("pencil") + '</span>';
3667
+ chip.innerHTML = iconHtml("sparkles") +
3668
+ '<span class="suggestion-chip-text">' + escapeHtml(suggestion) + '</span>';
3667
3669
  chip.addEventListener("click", function () {
3668
3670
  inputEl.value = suggestion;
3669
3671
  hideSuggestionChips();
3670
3672
  sendMessage();
3671
3673
  });
3672
- chip.querySelector(".suggestion-chip-edit").addEventListener("click", function (e) {
3673
- e.stopPropagation();
3674
- inputEl.value = suggestion;
3675
- inputEl.focus();
3676
- inputEl.select();
3677
- autoResize();
3678
- hideSuggestionChips();
3679
- });
3680
3674
  suggestionChipsEl.appendChild(chip);
3681
3675
  suggestionChipsEl.classList.remove("hidden");
3682
3676
  refreshIcons();
@@ -3718,6 +3712,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
3718
3712
  setStatus("connected");
3719
3713
  if (!loopActive) enableMainInput();
3720
3714
  resetUsage();
3715
+ resetTurnMetaCost();
3721
3716
  resetContext();
3722
3717
  // Clear header indicators
3723
3718
  clearRateLimitIndicator();
@@ -4711,6 +4706,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4711
4706
  break;
4712
4707
 
4713
4708
  case "rewind_complete":
4709
+ onRewindComplete();
4714
4710
  setRewindMode(false);
4715
4711
  var rewindText = "Rewound to earlier point. Files have been restored.";
4716
4712
  if (msg.mode === "chat") rewindText = "Conversation rewound to earlier point.";
@@ -4719,6 +4715,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4719
4715
  break;
4720
4716
 
4721
4717
  case "rewind_error":
4718
+ onRewindError();
4722
4719
  clearPendingRewindUuid();
4723
4720
  addSystemMessage(msg.text || "Rewind failed.", true);
4724
4721
  break;
@@ -4791,6 +4788,11 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4791
4788
 
4792
4789
  case "term_list":
4793
4790
  handleTermList(msg);
4791
+ updateTerminalList(msg.terminals);
4792
+ break;
4793
+
4794
+ case "context_sources_state":
4795
+ handleContextSourcesState(msg);
4794
4796
  break;
4795
4797
 
4796
4798
  case "term_created":
@@ -4980,6 +4982,22 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
4980
4982
  // Update mate sidebar if currently viewing this mate
4981
4983
  if (dmMode && dmTargetUser && dmTargetUser.isMate && dmTargetUser.id === msg.mate.id) {
4982
4984
  updateMateSidebarProfile(msg.mate);
4985
+ // Sync dmTargetUser so subsequent renders use fresh data
4986
+ var mp2 = msg.mate.profile || {};
4987
+ dmTargetUser.displayName = mp2.displayName || msg.mate.name || dmTargetUser.displayName;
4988
+ dmTargetUser.avatarStyle = mp2.avatarStyle || dmTargetUser.avatarStyle;
4989
+ dmTargetUser.avatarSeed = mp2.avatarSeed || dmTargetUser.avatarSeed;
4990
+ dmTargetUser.avatarColor = mp2.avatarColor || dmTargetUser.avatarColor;
4991
+ dmTargetUser.avatarCustom = mp2.avatarCustom || "";
4992
+ dmTargetUser.profile = mp2;
4993
+ // Refresh body dataset so new chat bubbles use the updated avatar
4994
+ document.body.dataset.mateAvatarUrl = mateAvatarUrl(dmTargetUser, 36);
4995
+ document.body.dataset.mateName = mp2.displayName || msg.mate.name || "";
4996
+ // Update existing chat bubble avatars
4997
+ var mateAvis = document.querySelectorAll(".dm-bubble-avatar-mate");
4998
+ for (var mbi = 0; mbi < mateAvis.length; mbi++) {
4999
+ mateAvis[mbi].src = document.body.dataset.mateAvatarUrl;
5000
+ }
4983
5001
  }
4984
5002
  // Update DM header if currently chatting with this mate
4985
5003
  if (dmMode && dmTargetUser && dmTargetUser.id === msg.mate.id) {
@@ -5741,6 +5759,12 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
5741
5759
  fileViewerEl: $("file-viewer"),
5742
5760
  });
5743
5761
 
5762
+ // --- Context Sources ---
5763
+ initContextSources({
5764
+ get ws() { return ws; },
5765
+ get connected() { return connected; },
5766
+ });
5767
+
5744
5768
  // --- Playbook Engine ---
5745
5769
  initPlaybook();
5746
5770
 
@@ -271,6 +271,165 @@
271
271
  border-color: var(--error);
272
272
  }
273
273
 
274
+ /* ==========================================================================
275
+ Context Sources — chips above input
276
+ ========================================================================== */
277
+
278
+ #context-sources-bar {
279
+ display: flex;
280
+ align-items: center;
281
+ flex-wrap: wrap;
282
+ gap: 4px;
283
+ padding: 0 8px 4px;
284
+ position: relative;
285
+ }
286
+
287
+ #context-sources-add {
288
+ display: inline-flex;
289
+ align-items: center;
290
+ gap: 6px;
291
+ padding: 6px 12px;
292
+ border-radius: 6px;
293
+ border: 1px dashed var(--border);
294
+ background: transparent;
295
+ color: var(--text-dimmer);
296
+ font-family: inherit;
297
+ font-size: 12px;
298
+ font-weight: 500;
299
+ cursor: pointer;
300
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
301
+ }
302
+
303
+ #context-sources-add .lucide { width: 12px; height: 12px; }
304
+
305
+ #context-sources-add:hover {
306
+ color: var(--text-secondary);
307
+ border-color: var(--text-dimmer);
308
+ background: var(--sidebar-hover);
309
+ }
310
+
311
+ #context-sources-chips {
312
+ display: flex;
313
+ align-items: center;
314
+ flex-wrap: wrap;
315
+ gap: 4px;
316
+ }
317
+
318
+ .context-chip {
319
+ display: inline-flex;
320
+ align-items: stretch;
321
+ padding: 0;
322
+ border-radius: 8px;
323
+ background: var(--bg-alt);
324
+ color: var(--text);
325
+ font-size: 13px;
326
+ font-weight: 500;
327
+ line-height: 1;
328
+ white-space: nowrap;
329
+ border: 1px solid var(--border);
330
+ transition: border-color 0.15s;
331
+ }
332
+
333
+ .context-chip-label {
334
+ display: inline-flex;
335
+ align-items: center;
336
+ gap: 6px;
337
+ padding: 6px 10px 6px 12px;
338
+ }
339
+
340
+ .context-chip-label .lucide { width: 14px; height: 14px; flex-shrink: 0; color: var(--accent); }
341
+
342
+ .context-chip-remove {
343
+ display: inline-flex;
344
+ align-items: center;
345
+ justify-content: center;
346
+ width: 30px;
347
+ border: none;
348
+ border-left: 1px solid var(--border);
349
+ background: transparent;
350
+ color: var(--text-muted);
351
+ cursor: pointer;
352
+ padding: 0;
353
+ border-radius: 0 8px 8px 0;
354
+ transition: color 0.15s, background 0.15s;
355
+ }
356
+
357
+ .context-chip-remove:hover {
358
+ color: var(--text);
359
+ background: rgba(var(--overlay-rgb), 0.08);
360
+ }
361
+
362
+ .context-chip-remove .lucide { width: 14px; height: 14px; }
363
+
364
+ #context-sources-picker.hidden { display: none; }
365
+
366
+ #context-sources-picker {
367
+ position: absolute;
368
+ bottom: calc(100% + 4px);
369
+ left: 0;
370
+ min-width: 200px;
371
+ background: var(--sidebar-bg);
372
+ border: 1px solid var(--border);
373
+ border-radius: 10px;
374
+ padding: 4px 0;
375
+ box-shadow: 0 4px 12px rgba(var(--shadow-rgb), 0.15);
376
+ z-index: 200;
377
+ animation: ctxPickerAppear 0.12s ease-out;
378
+ }
379
+
380
+ @keyframes ctxPickerAppear {
381
+ from { opacity: 0; transform: scale(0.95); }
382
+ to { opacity: 1; transform: scale(1); }
383
+ }
384
+
385
+ .context-picker-section-label {
386
+ font-size: 10px;
387
+ font-weight: 600;
388
+ color: var(--text-dimmer);
389
+ text-transform: uppercase;
390
+ letter-spacing: 0.5px;
391
+ padding: 8px 12px 4px;
392
+ }
393
+
394
+ .context-picker-item {
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 8px;
398
+ width: 100%;
399
+ padding: 8px 12px;
400
+ font-size: 13px;
401
+ color: var(--text-secondary);
402
+ background: none;
403
+ border: none;
404
+ cursor: pointer;
405
+ transition: background 0.15s;
406
+ }
407
+
408
+ .context-picker-item:hover {
409
+ background: rgba(var(--overlay-rgb), 0.05);
410
+ }
411
+
412
+ .context-picker-item .lucide { width: 14px; height: 14px; flex-shrink: 0; }
413
+
414
+ .context-picker-check {
415
+ margin-left: auto;
416
+ width: 14px;
417
+ height: 14px;
418
+ color: var(--accent);
419
+ display: none;
420
+ }
421
+
422
+ .context-picker-item.active .context-picker-check {
423
+ display: block;
424
+ }
425
+
426
+ .context-picker-empty {
427
+ padding: 12px;
428
+ color: var(--text-dimmer);
429
+ font-size: 13px;
430
+ text-align: center;
431
+ }
432
+
274
433
  /* ==========================================================================
275
434
  Input Area — Claude-style unified container
276
435
  ========================================================================== */
@@ -521,98 +680,53 @@
521
680
  #suggestion-chips {
522
681
  display: flex;
523
682
  flex-wrap: wrap;
524
- gap: 6px;
525
- padding: 8px 6px;
683
+ gap: 4px;
684
+ padding: 4px 6px;
526
685
  position: absolute;
527
686
  bottom: 100%;
528
687
  left: 0;
529
688
  right: 0;
530
689
  z-index: 5;
531
- background: transparent;
532
690
  }
533
691
 
534
692
  #suggestion-chips.hidden { display: none; }
535
693
 
536
694
  .suggestion-chip {
537
695
  display: inline-flex;
538
- align-items: stretch;
539
- padding: 0;
540
- border-radius: 16px;
696
+ align-items: center;
697
+ gap: 5px;
698
+ padding: 4px 10px 4px 8px;
699
+ border-radius: 6px;
541
700
  border: 1px solid var(--border);
542
- background: var(--bg-alt);
701
+ background: rgba(var(--overlay-rgb), 0.08);
543
702
  color: var(--text-secondary);
544
- font-size: 13px;
703
+ font-size: 12px;
545
704
  font-family: inherit;
546
705
  cursor: pointer;
547
- transition: border-color 0.15s;
706
+ transition: border-color 0.15s, background 0.15s, color 0.15s;
548
707
  text-align: left;
549
708
  max-width: 100%;
550
- line-height: 1.3;
551
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
709
+ line-height: 1.2;
552
710
  }
553
711
 
554
712
  .suggestion-chip:hover {
555
713
  border-color: var(--accent);
714
+ background: var(--accent-bg);
715
+ color: var(--accent);
556
716
  }
557
717
 
558
718
  .suggestion-chip .lucide {
559
- width: 14px;
560
- height: 14px;
719
+ width: 12px;
720
+ height: 12px;
561
721
  flex-shrink: 0;
562
722
  color: var(--accent);
563
723
  }
564
724
 
565
- .suggestion-chip-send {
566
- display: inline-flex;
567
- align-items: center;
568
- gap: 5px;
569
- flex: 1;
570
- min-width: 0;
571
- padding: 8px 10px 8px 14px;
572
- border-radius: 16px 0 0 16px;
573
- transition: background 0.15s, color 0.15s;
574
- }
575
-
576
- .suggestion-chip-send:hover {
577
- background: var(--accent-bg);
578
- color: var(--accent);
579
- }
580
-
581
725
  .suggestion-chip-text {
582
726
  flex: 1;
583
727
  min-width: 0;
584
728
  }
585
729
 
586
- .suggestion-chip-edit {
587
- display: inline-flex;
588
- align-items: center;
589
- justify-content: center;
590
- padding: 8px 12px;
591
- border-left: 1px solid var(--border);
592
- border-radius: 0 16px 16px 0;
593
- background: rgba(128, 128, 128, 0.07);
594
- cursor: pointer;
595
- transition: background 0.15s, border-color 0.15s;
596
- }
597
-
598
- .suggestion-chip:hover .suggestion-chip-edit {
599
- border-left-color: var(--accent);
600
- }
601
-
602
- .suggestion-chip-edit:hover {
603
- background: var(--accent-bg);
604
- }
605
-
606
- .suggestion-chip-edit .lucide {
607
- width: 14px;
608
- height: 14px;
609
- color: var(--text-secondary);
610
- }
611
-
612
- .suggestion-chip-edit:hover .lucide {
613
- color: var(--accent);
614
- }
615
-
616
730
  /* ==========================================================================
617
731
  Animations
618
732
  ========================================================================== */
@@ -474,6 +474,13 @@
474
474
 
475
475
  #config-chip .lucide { width: 10px; height: 10px; }
476
476
  #config-chip .config-chip-icon { display: none; }
477
+
478
+ @media (max-width: 1000px) {
479
+ #config-chip .config-chip-icon { display: inline-flex; width: 16px; height: 16px; }
480
+ #config-chip-label { display: none; }
481
+ #config-chip .lucide:last-child { display: none; }
482
+ #config-chip { padding: 0 6px; }
483
+ }
477
484
  #config-chip:hover { color: var(--text-secondary); background: rgba(var(--overlay-rgb),0.06); }
478
485
  #config-chip.active { color: var(--text-secondary); background: rgba(var(--overlay-rgb),0.06); }
479
486
 
@@ -257,6 +257,16 @@
257
257
  letter-spacing: 0.5px;
258
258
  }
259
259
 
260
+ /* --- Section labels --- */
261
+ .sidebar-section-label {
262
+ font-size: 11px;
263
+ font-weight: 600;
264
+ color: var(--text-dimmer);
265
+ text-transform: uppercase;
266
+ letter-spacing: 0.5px;
267
+ padding: 4px 12px 2px;
268
+ }
269
+
260
270
  /* --- Tools section --- */
261
271
  #sidebar-tools {
262
272
  flex-shrink: 0;
@@ -415,6 +415,13 @@
415
415
  <div id="input-wrapper">
416
416
  <div id="mention-menu"></div>
417
417
  <div id="slash-menu"></div>
418
+ <div id="context-sources-bar">
419
+ <div id="context-sources-chips"></div>
420
+ <button id="context-sources-add" type="button" title="Add context source"><i data-lucide="plus"></i><span>Context Sources</span></button>
421
+ <div id="context-sources-picker" class="hidden">
422
+ <div class="context-picker-section" id="context-picker-terminals"></div>
423
+ </div>
424
+ </div>
418
425
  <div id="suggestion-chips" class="hidden"></div>
419
426
  <div id="input-row">
420
427
  <div id="context-mini" class="hidden">
@@ -0,0 +1,226 @@
1
+ // Context Sources — attach terminal output (and future browser tabs) as context for Claude
2
+
3
+ var ctx = null;
4
+ var activeSourceIds = new Set();
5
+ var terminalList = []; // synced from terminal module's term_list
6
+
7
+ export function initContextSources(_ctx) {
8
+ ctx = _ctx;
9
+
10
+ var addBtn = document.getElementById("context-sources-add");
11
+ var picker = document.getElementById("context-sources-picker");
12
+
13
+ addBtn.addEventListener("click", function(e) {
14
+ e.stopPropagation();
15
+ if (picker.classList.contains("hidden")) {
16
+ renderPicker();
17
+ picker.classList.remove("hidden");
18
+ document.addEventListener("click", closePicker, true);
19
+ } else {
20
+ closePicker();
21
+ }
22
+ });
23
+
24
+ picker.addEventListener("click", function(e) {
25
+ e.stopPropagation();
26
+ });
27
+ }
28
+
29
+ function closePicker() {
30
+ var picker = document.getElementById("context-sources-picker");
31
+ picker.classList.add("hidden");
32
+ document.removeEventListener("click", closePicker, true);
33
+ }
34
+
35
+ // Restore state from server
36
+ export function handleContextSourcesState(msg) {
37
+ var saved = msg.active || [];
38
+ activeSourceIds = new Set(saved);
39
+ renderChips();
40
+ }
41
+
42
+ // Save active sources to server
43
+ function saveToServer() {
44
+ if (ctx && ctx.ws && ctx.connected) {
45
+ ctx.ws.send(JSON.stringify({
46
+ type: "context_sources_save",
47
+ active: Array.from(activeSourceIds)
48
+ }));
49
+ }
50
+ }
51
+
52
+ // Called when term_list arrives from server
53
+ export function updateTerminalList(terminals) {
54
+ terminalList = terminals || [];
55
+
56
+ // Remove active sources that no longer exist
57
+ var changed = false;
58
+ for (var id of activeSourceIds) {
59
+ if (id.startsWith("term:")) {
60
+ var termId = parseInt(id.split(":")[1], 10);
61
+ var found = false;
62
+ for (var i = 0; i < terminalList.length; i++) {
63
+ if (terminalList[i].id === termId) { found = true; break; }
64
+ }
65
+ if (!found) {
66
+ activeSourceIds.delete(id);
67
+ changed = true;
68
+ }
69
+ }
70
+ }
71
+
72
+ if (changed) saveToServer();
73
+ renderChips();
74
+
75
+ // If picker is open, re-render it
76
+ var picker = document.getElementById("context-sources-picker");
77
+ if (!picker.classList.contains("hidden")) {
78
+ renderPicker();
79
+ }
80
+ }
81
+
82
+ function toggleSource(sourceId) {
83
+ if (activeSourceIds.has(sourceId)) {
84
+ activeSourceIds.delete(sourceId);
85
+ } else {
86
+ activeSourceIds.add(sourceId);
87
+ }
88
+ saveToServer();
89
+ renderChips();
90
+ renderPicker();
91
+ }
92
+
93
+ function removeSource(sourceId) {
94
+ activeSourceIds.delete(sourceId);
95
+ saveToServer();
96
+ renderChips();
97
+
98
+ var picker = document.getElementById("context-sources-picker");
99
+ if (!picker.classList.contains("hidden")) {
100
+ renderPicker();
101
+ }
102
+ }
103
+
104
+ function renderChips() {
105
+ var container = document.getElementById("context-sources-chips");
106
+ container.innerHTML = "";
107
+
108
+ for (var id of activeSourceIds) {
109
+ var chip = document.createElement("div");
110
+ chip.className = "context-chip";
111
+
112
+ var label = getSourceLabel(id);
113
+ var iconName = getSourceIcon(id);
114
+
115
+ var labelEl = document.createElement("span");
116
+ labelEl.className = "context-chip-label";
117
+ labelEl.innerHTML =
118
+ '<i data-lucide="' + iconName + '"></i>' +
119
+ '<span>' + escapeHtml(label) + '</span>';
120
+ chip.appendChild(labelEl);
121
+
122
+ var removeBtn = document.createElement("button");
123
+ removeBtn.type = "button";
124
+ removeBtn.className = "context-chip-remove";
125
+ removeBtn.title = "Remove";
126
+ removeBtn.innerHTML = '<i data-lucide="minus"></i>';
127
+ removeBtn.setAttribute("data-source-id", id);
128
+ removeBtn.addEventListener("click", function(e) {
129
+ e.stopPropagation();
130
+ removeSource(this.getAttribute("data-source-id"));
131
+ if (typeof lucide !== "undefined") lucide.createIcons();
132
+ });
133
+
134
+ chip.appendChild(removeBtn);
135
+ container.appendChild(chip);
136
+ }
137
+
138
+ // Update add button label
139
+ var addBtn = document.getElementById("context-sources-add");
140
+ var labelSpan = addBtn.querySelector("span");
141
+ if (activeSourceIds.size > 0) {
142
+ labelSpan.textContent = "";
143
+ labelSpan.style.display = "none";
144
+ } else {
145
+ labelSpan.textContent = "Context Sources";
146
+ labelSpan.style.display = "";
147
+ }
148
+
149
+ if (typeof lucide !== "undefined") lucide.createIcons();
150
+ }
151
+
152
+ function renderPicker() {
153
+ var section = document.getElementById("context-picker-terminals");
154
+ section.innerHTML = "";
155
+
156
+ var sectionLabel = document.createElement("div");
157
+ sectionLabel.className = "context-picker-section-label";
158
+ sectionLabel.textContent = "Terminals";
159
+ section.appendChild(sectionLabel);
160
+
161
+ if (terminalList.length === 0) {
162
+ var empty = document.createElement("div");
163
+ empty.className = "context-picker-empty";
164
+ empty.textContent = "No terminals open";
165
+ section.appendChild(empty);
166
+ return;
167
+ }
168
+
169
+ for (var i = 0; i < terminalList.length; i++) {
170
+ var term = terminalList[i];
171
+ var sourceId = "term:" + term.id;
172
+ var isActive = activeSourceIds.has(sourceId);
173
+
174
+ var item = document.createElement("div");
175
+ item.className = "context-picker-item" + (isActive ? " active" : "");
176
+ item.setAttribute("data-source-id", sourceId);
177
+
178
+ item.innerHTML =
179
+ '<i data-lucide="square-terminal"></i>' +
180
+ '<span>' + escapeHtml(term.title || ("Terminal " + term.id)) + '</span>' +
181
+ '<i data-lucide="check" class="context-picker-check"></i>';
182
+
183
+ item.addEventListener("click", function() {
184
+ toggleSource(this.getAttribute("data-source-id"));
185
+ if (typeof lucide !== "undefined") lucide.createIcons();
186
+ });
187
+
188
+ section.appendChild(item);
189
+ }
190
+
191
+ if (typeof lucide !== "undefined") lucide.createIcons();
192
+ }
193
+
194
+ function getSourceLabel(id) {
195
+ if (id.startsWith("term:")) {
196
+ var termId = parseInt(id.split(":")[1], 10);
197
+ for (var i = 0; i < terminalList.length; i++) {
198
+ if (terminalList[i].id === termId) {
199
+ return terminalList[i].title || ("Terminal " + termId);
200
+ }
201
+ }
202
+ return "Terminal " + termId;
203
+ }
204
+ return id;
205
+ }
206
+
207
+ function getSourceIcon(id) {
208
+ if (id.startsWith("term:")) return "square-terminal";
209
+ return "circle";
210
+ }
211
+
212
+ // Get active source IDs (for use when sending messages)
213
+ export function getActiveSources() {
214
+ return Array.from(activeSourceIds);
215
+ }
216
+
217
+ // Check if any sources are active
218
+ export function hasActiveSources() {
219
+ return activeSourceIds.size > 0;
220
+ }
221
+
222
+ function escapeHtml(str) {
223
+ var div = document.createElement("div");
224
+ div.textContent = str;
225
+ return div.innerHTML;
226
+ }
@@ -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
  }
@@ -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;
@@ -28,6 +28,7 @@ function createTerminalManager(opts) {
28
28
  pty: pty,
29
29
  scrollback: [],
30
30
  scrollbackSize: 0,
31
+ totalBytesWritten: 0,
31
32
  cols: cols || 80,
32
33
  rows: rows || 24,
33
34
  title: "Terminal " + id,
@@ -38,11 +39,13 @@ function createTerminalManager(opts) {
38
39
  };
39
40
 
40
41
  pty.onData(function (data) {
41
- // Buffer scrollback
42
- session.scrollback.push(data);
42
+ // Buffer scrollback with timestamps
43
+ var ts = Date.now();
44
+ session.scrollback.push({ ts: ts, data: data });
43
45
  session.scrollbackSize += data.length;
46
+ session.totalBytesWritten += data.length;
44
47
  while (session.scrollbackSize > SCROLLBACK_MAX && session.scrollback.length > 1) {
45
- session.scrollbackSize -= session.scrollback[0].length;
48
+ session.scrollbackSize -= session.scrollback[0].data.length;
46
49
  session.scrollback.shift();
47
50
  }
48
51
 
@@ -81,7 +84,7 @@ function createTerminalManager(opts) {
81
84
 
82
85
  // Replay scrollback only for newly attached clients
83
86
  if (!alreadySubscribed && session.scrollback.length > 0) {
84
- var replay = session.scrollback.join("");
87
+ var replay = session.scrollback.map(function(c) { return c.data; }).join("");
85
88
  sendTo(ws, { type: "term_output", id: id, data: replay });
86
89
  }
87
90
 
@@ -176,6 +179,18 @@ function createTerminalManager(opts) {
176
179
  return result;
177
180
  }
178
181
 
182
+ function getScrollback(id) {
183
+ var session = terminals.get(id);
184
+ if (!session) return null;
185
+ var content = session.scrollback.map(function(c) { return c.data; }).join("");
186
+ return {
187
+ content: content,
188
+ chunks: session.scrollback,
189
+ totalBytesWritten: session.totalBytesWritten,
190
+ bufferStart: session.totalBytesWritten - content.length
191
+ };
192
+ }
193
+
179
194
  function destroyAll() {
180
195
  for (var session of terminals.values()) {
181
196
  if (session.pty) {
@@ -196,6 +211,7 @@ function createTerminalManager(opts) {
196
211
  close: close,
197
212
  rename: rename,
198
213
  list: list,
214
+ getScrollback: getScrollback,
199
215
  destroyAll: destroyAll,
200
216
  };
201
217
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.26.0-beta.2",
3
+ "version": "2.26.0-beta.4",
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",