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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/cli.js +5 -9
  2. package/lib/browser-mcp-server.js +496 -0
  3. package/lib/daemon.js +1 -1
  4. package/lib/os-users.js +23 -0
  5. package/lib/project-debate.js +206 -88
  6. package/lib/project-mate-interaction.js +766 -0
  7. package/lib/project-memory.js +677 -0
  8. package/lib/project.js +546 -1361
  9. package/lib/public/app.js +817 -175
  10. package/lib/public/css/debate.css +224 -2
  11. package/lib/public/css/icon-strip.css +10 -10
  12. package/lib/public/css/input.css +263 -83
  13. package/lib/public/css/mates.css +56 -57
  14. package/lib/public/css/mention.css +7 -4
  15. package/lib/public/css/menus.css +7 -0
  16. package/lib/public/css/messages.css +17 -0
  17. package/lib/public/css/mobile-nav.css +3 -1
  18. package/lib/public/css/overlays.css +181 -0
  19. package/lib/public/css/rewind.css +79 -0
  20. package/lib/public/css/server-settings.css +1 -0
  21. package/lib/public/css/sidebar.css +10 -0
  22. package/lib/public/css/title-bar.css +189 -3
  23. package/lib/public/index.html +53 -16
  24. package/lib/public/modules/context-sources.js +313 -0
  25. package/lib/public/modules/debate.js +184 -97
  26. package/lib/public/modules/input.js +18 -1
  27. package/lib/public/modules/mate-knowledge.js +11 -11
  28. package/lib/public/modules/mate-memory.js +5 -5
  29. package/lib/public/modules/mate-sidebar.js +13 -9
  30. package/lib/public/modules/mention.js +40 -2
  31. package/lib/public/modules/notifications.js +109 -1
  32. package/lib/public/modules/rewind.js +36 -0
  33. package/lib/public/modules/sidebar.js +107 -28
  34. package/lib/public/modules/terminal.js +8 -0
  35. package/lib/public/modules/theme.js +2 -1
  36. package/lib/public/modules/tools.js +69 -24
  37. package/lib/sdk-bridge.js +81 -7
  38. package/lib/sdk-worker.js +13 -1
  39. package/lib/server.js +42 -0
  40. package/lib/sessions.js +39 -7
  41. package/lib/terminal-manager.js +36 -6
  42. package/package.json +2 -2
package/lib/sdk-bridge.js CHANGED
@@ -134,6 +134,7 @@ function createSDKBridge(opts) {
134
134
  var mateDisplayName = opts.mateDisplayName || "";
135
135
  var isMate = opts.isMate || (slug.indexOf("mate-") === 0);
136
136
  var dangerouslySkipPermissions = opts.dangerouslySkipPermissions || false;
137
+ var mcpServers = opts.mcpServers || null;
137
138
  var onProcessingChanged = opts.onProcessingChanged || function () {};
138
139
  var onTurnDone = opts.onTurnDone || null;
139
140
 
@@ -188,6 +189,10 @@ function createSDKBridge(opts) {
188
189
  sm.sendAndRecord(session, obj);
189
190
  }
190
191
 
192
+ function sendToSession(session, obj) {
193
+ sm.sendToSession(session, obj);
194
+ }
195
+
191
196
  function processSDKMessage(session, parsed) {
192
197
  // Timing: log key SDK milestones relative to query start
193
198
  if (session._queryStartTs) {
@@ -435,6 +440,15 @@ function createSDKBridge(opts) {
435
440
  session.isProcessing = false;
436
441
  session.rateLimitResetsAt = null; // clear on success
437
442
  onProcessingChanged();
443
+ // Fetch rich context usage breakdown (fire-and-forget, non-blocking)
444
+ if (session.queryInstance && typeof session.queryInstance.getContextUsage === "function") {
445
+ session.queryInstance.getContextUsage().then(function(ctxUsage) {
446
+ session.lastContextUsage = ctxUsage;
447
+ sendToSession(session, { type: "context_usage", data: ctxUsage });
448
+ }).catch(function(e) {
449
+ console.error("[sdk-bridge] getContextUsage failed (non-fatal):", e.message || e);
450
+ });
451
+ }
438
452
  var lastStreamInput = session.lastStreamInputTokens || null;
439
453
  session.lastStreamInputTokens = null;
440
454
  sendAndRecord(session, {
@@ -581,8 +595,26 @@ function createSDKBridge(opts) {
581
595
  isUsingOverage: info.isUsingOverage || false,
582
596
  });
583
597
  // Track rejection for auto-continue / scheduled message support
584
- if (info.status === "rejected" && info.resetsAt) {
598
+ // Skip if using overage (extra usage) user can continue immediately
599
+ if (info.status === "rejected" && info.resetsAt && !info.isUsingOverage) {
585
600
  session.rateLimitResetsAt = info.resetsAt * 1000;
601
+
602
+ // Defensive: if query already completed before this event arrived,
603
+ // schedule auto-continue now (handles race condition where
604
+ // rate_limit_event arrives after the result/done event).
605
+ if (!session.isProcessing && !session.scheduledMessage && !session.destroying) {
606
+ var lateACEnabled = session.onQueryComplete ||
607
+ (typeof opts.getAutoContinueSetting === "function" && opts.getAutoContinueSetting(session));
608
+ if (lateACEnabled) {
609
+ var lateResetsAt = session.rateLimitResetsAt;
610
+ session.rateLimitResetsAt = null;
611
+ session.rateLimitAutoContinuePending = true;
612
+ console.log("[sdk-bridge] Rate limit event arrived late, scheduling auto-continue for session " + session.localId);
613
+ if (typeof opts.scheduleMessage === "function") {
614
+ opts.scheduleMessage(session, "continue", lateResetsAt);
615
+ }
616
+ }
617
+ }
586
618
  }
587
619
  }
588
620
 
@@ -1115,6 +1147,7 @@ function createSDKBridge(opts) {
1115
1147
  agentProgressSummaries: true,
1116
1148
  };
1117
1149
 
1150
+ if (mcpServers) queryOptions.mcpServers = mcpServers;
1118
1151
  if (sm.currentModel) queryOptions.model = sm.currentModel;
1119
1152
  if (sm.currentEffort) queryOptions.effort = sm.currentEffort;
1120
1153
  if (sm.currentBetas && sm.currentBetas.length > 0) queryOptions.betas = sm.currentBetas;
@@ -1138,6 +1171,8 @@ function createSDKBridge(opts) {
1138
1171
  if (session.lastRewindUuid) {
1139
1172
  queryOptions.resumeSessionAt = session.lastRewindUuid;
1140
1173
  delete session.lastRewindUuid;
1174
+ // Persist the deletion so server restarts don't re-use a stale UUID
1175
+ sm.saveSessionFile(session);
1141
1176
  }
1142
1177
  }
1143
1178
 
@@ -1194,6 +1229,11 @@ function createSDKBridge(opts) {
1194
1229
  });
1195
1230
  break;
1196
1231
 
1232
+ case "context_usage":
1233
+ session.lastContextUsage = msg.data;
1234
+ sendToSession(session, { type: "context_usage", data: msg.data });
1235
+ break;
1236
+
1197
1237
  case "query_done":
1198
1238
  console.log("[sdk-bridge] IPC query_done received, pid=" + (worker.process ? worker.process.pid : "?"));
1199
1239
  // Mark that we received a proper IPC completion, so the exit
@@ -1299,6 +1339,8 @@ function createSDKBridge(opts) {
1299
1339
  sm.broadcastSessionList();
1300
1340
  }
1301
1341
  cleanupSessionWorker(session, worker);
1342
+ // Mark session as done so late rate_limit_event can detect race condition
1343
+ session.isProcessing = false;
1302
1344
  // Auto-continue on rate limit (scheduler sessions, or user setting)
1303
1345
  var workerDidScheduleAC = false;
1304
1346
  var workerACEnabled = session.onQueryComplete || (typeof opts.getAutoContinueSetting === "function" && opts.getAutoContinueSetting(session));
@@ -1493,6 +1535,18 @@ function createSDKBridge(opts) {
1493
1535
  return Promise.resolve({ behavior: "allow", updatedInput: input });
1494
1536
  }
1495
1537
 
1538
+ // Auto-approve safe browser MCP tools.
1539
+ // Only watch/unwatch: user explicitly chose which tab to share.
1540
+ // Everything else (screenshot, read_page, list_tabs, etc.) can expose
1541
+ // content from tabs the user didn't intend to share, so require approval.
1542
+ var safeBrowserTools = { browser_watch_tab: true, browser_unwatch_tab: true };
1543
+ if (toolName.indexOf("mcp__") === 0 && toolName.indexOf("__browser_") !== -1) {
1544
+ var mcpToolName = toolName.substring(toolName.lastIndexOf("__") + 2);
1545
+ if (safeBrowserTools[mcpToolName]) {
1546
+ return Promise.resolve({ behavior: "allow", updatedInput: input });
1547
+ }
1548
+ }
1549
+
1496
1550
  // Auto-approve safe Bash commands (read-only, non-destructive)
1497
1551
  // Applies to ALL sessions (mates and regular projects alike).
1498
1552
  // These are purely read-only commands that cannot modify files, install
@@ -1687,10 +1741,16 @@ function createSDKBridge(opts) {
1687
1741
  }
1688
1742
 
1689
1743
  async function processQueryStream(session) {
1744
+ // Capture references at start so we only clean up OUR resources in finally,
1745
+ // not resources from a newer query that may have been created after an abort.
1746
+ var myQueryInstance = session.queryInstance;
1747
+ var myMessageQueue = session.messageQueue;
1748
+ var myAbortController = session.abortController;
1690
1749
  try {
1691
- for await (var msg of session.queryInstance) {
1750
+ for await (var msg of myQueryInstance) {
1692
1751
  processSDKMessage(session, msg);
1693
1752
  }
1753
+ // (getContextUsage moved to processSDKMessage result handler -- fire-and-forget)
1694
1754
  // Stream ended normally after a task stop — no "result" message was sent,
1695
1755
  // so the session is still marked as processing. Send interrupted feedback.
1696
1756
  if (session.isProcessing && session.taskStopRequested) {
@@ -1704,7 +1764,7 @@ function createSDKBridge(opts) {
1704
1764
  if (session.isProcessing) {
1705
1765
  session.isProcessing = false;
1706
1766
  onProcessingChanged();
1707
- if (err.name === "AbortError" || (session.abortController && session.abortController.signal.aborted) || session.taskStopRequested) {
1767
+ if (err.name === "AbortError" || (myAbortController && myAbortController.signal.aborted) || session.taskStopRequested) {
1708
1768
  if (!session.destroying) {
1709
1769
  sendAndRecord(session, { type: "info", text: "Interrupted \u00b7 What should Claude do instead?" });
1710
1770
  sendAndRecord(session, { type: "done", code: 0 });
@@ -1777,22 +1837,29 @@ function createSDKBridge(opts) {
1777
1837
  } finally {
1778
1838
  // Close the SDK query to terminate the underlying claude child process.
1779
1839
  // Without this, the process stays alive indefinitely (single-user mode).
1780
- if (session.queryInstance) {
1840
+ // Only clean up if the session still references OUR resources.
1841
+ // A rewind + new startQuery may have already replaced these with
1842
+ // a newer query — clobbering them would kill the new query.
1843
+ if (session.queryInstance === myQueryInstance) {
1781
1844
  try {
1782
1845
  if (typeof session.queryInstance.close === "function") {
1783
1846
  session.queryInstance.close();
1784
1847
  }
1785
1848
  } catch (e) {}
1849
+ session.queryInstance = null;
1786
1850
  }
1787
- session.queryInstance = null;
1788
- session.messageQueue = null;
1789
- session.abortController = null;
1851
+ if (session.messageQueue === myMessageQueue) session.messageQueue = null;
1852
+ if (session.abortController === myAbortController) session.abortController = null;
1790
1853
  session.taskStopRequested = false;
1791
1854
  session.pendingPermissions = {};
1792
1855
  session.pendingAskUser = {};
1793
1856
  session.pendingElicitations = {};
1794
1857
 
1795
1858
  // Auto-continue on rate limit (scheduler sessions, or user setting)
1859
+ // Mark session as done processing so the late rate_limit_event handler
1860
+ // can detect the race condition and schedule auto-continue itself.
1861
+ session.isProcessing = false;
1862
+
1796
1863
  var didScheduleAutoContinue = false;
1797
1864
  var acEnabled = session.onQueryComplete || (typeof opts.getAutoContinueSetting === "function" && opts.getAutoContinueSetting(session));
1798
1865
  if (session.rateLimitResetsAt && session.rateLimitResetsAt > Date.now()
@@ -1805,6 +1872,10 @@ function createSDKBridge(opts) {
1805
1872
  if (typeof opts.scheduleMessage === "function") {
1806
1873
  opts.scheduleMessage(session, "continue", acResetsAt);
1807
1874
  }
1875
+ } else if (acEnabled && !session.destroying) {
1876
+ // Log why auto-continue was not scheduled (for debugging)
1877
+ console.log("[sdk-bridge] Query done, auto-continue enabled but not scheduled: rateLimitResetsAt=" +
1878
+ session.rateLimitResetsAt + " (will rely on late rate_limit_event handler)");
1808
1879
  }
1809
1880
 
1810
1881
  // Ralph Loop: notify completion so loop orchestrator can proceed
@@ -1911,6 +1982,7 @@ function createSDKBridge(opts) {
1911
1982
  abortController: session.abortController,
1912
1983
  promptSuggestions: true,
1913
1984
  agentProgressSummaries: true,
1985
+ mcpServers: mcpServers || undefined,
1914
1986
  canUseTool: function(toolName, input, toolOpts) {
1915
1987
  return handleCanUseTool(session, toolName, input, toolOpts);
1916
1988
  },
@@ -1952,6 +2024,8 @@ function createSDKBridge(opts) {
1952
2024
  if (session.lastRewindUuid) {
1953
2025
  queryOptions.resumeSessionAt = session.lastRewindUuid;
1954
2026
  delete session.lastRewindUuid;
2027
+ // Persist the deletion so server restarts don't re-use a stale UUID
2028
+ sm.saveSessionFile(session);
1955
2029
  }
1956
2030
  }
1957
2031
 
package/lib/sdk-worker.js CHANGED
@@ -351,7 +351,19 @@ async function handleQueryStart(msg) {
351
351
  }
352
352
  sendToDaemon({ type: "sdk_event", event: event });
353
353
  }
354
- perf("all events streamed (counts=" + JSON.stringify(eventCounts) + "), sending query_done");
354
+ perf("all events streamed (counts=" + JSON.stringify(eventCounts) + "), fetching context usage");
355
+ // Fetch context usage breakdown before queryInstance is cleared
356
+ try {
357
+ if (queryInstance && typeof queryInstance.getContextUsage === "function") {
358
+ var ctxUsage = await queryInstance.getContextUsage();
359
+ sendToDaemon({ type: "context_usage", data: ctxUsage });
360
+ perf("context usage sent");
361
+ }
362
+ } catch (e) {
363
+ // Non-fatal: SDK may have already shut down
364
+ console.error("[sdk-worker] getContextUsage failed (non-fatal):", e.message);
365
+ }
366
+ perf("sending query_done");
355
367
  sendToDaemon({ type: "query_done" });
356
368
  } catch (err) {
357
369
  var errMsg = err.message || String(err);
package/lib/server.js CHANGED
@@ -38,6 +38,24 @@ function httpGet(url) {
38
38
  });
39
39
  }
40
40
 
41
+ function httpGetBinary(url) {
42
+ return new Promise(function (resolve, reject) {
43
+ var mod = url.startsWith("https") ? https : http;
44
+ mod.get(url, { headers: { "User-Agent": "Clay/1.0" } }, function (resp) {
45
+ if (resp.statusCode >= 300 && resp.statusCode < 400 && resp.headers.location) {
46
+ return httpGetBinary(resp.headers.location).then(resolve, reject);
47
+ }
48
+ if (resp.statusCode !== 200) {
49
+ return reject(new Error("HTTP " + resp.statusCode));
50
+ }
51
+ var chunks = [];
52
+ resp.on("data", function (c) { chunks.push(c); });
53
+ resp.on("end", function () { resolve(Buffer.concat(chunks)); });
54
+ resp.on("error", reject);
55
+ }).on("error", reject);
56
+ });
57
+ }
58
+
41
59
  function fetchSkillsPage(url) {
42
60
  return httpGet(url).then(function (html) {
43
61
  // Data is inside self.__next_f.push() with escaped quotes: \"initialSkills\":[{\"source\":...}]
@@ -1018,6 +1036,28 @@ function createServer(opts) {
1018
1036
  return;
1019
1037
  }
1020
1038
 
1039
+ // Chrome extension download (proxy from GitHub)
1040
+ if (fullUrl === "/api/extension/download" && req.method === "GET") {
1041
+ if (!isRequestAuthed(req)) {
1042
+ res.writeHead(401, { "Content-Type": "application/json" });
1043
+ res.end(JSON.stringify({ error: "Unauthorized" }));
1044
+ return;
1045
+ }
1046
+ var archiveUrl = "https://github.com/chadbyte/clay-chrome/archive/refs/heads/main.zip";
1047
+ httpGetBinary(archiveUrl).then(function (buf) {
1048
+ res.writeHead(200, {
1049
+ "Content-Type": "application/zip",
1050
+ "Content-Disposition": 'attachment; filename="clay-chrome-extension.zip"',
1051
+ "Content-Length": buf.length,
1052
+ });
1053
+ res.end(buf);
1054
+ }).catch(function (err) {
1055
+ res.writeHead(502, { "Content-Type": "application/json" });
1056
+ res.end(JSON.stringify({ error: "Failed to download extension: " + (err.message || "unknown error") }));
1057
+ });
1058
+ return;
1059
+ }
1060
+
1021
1061
  // CORS preflight for cross-origin requests (HTTP onboarding → HTTPS)
1022
1062
  if (req.method === "OPTIONS") {
1023
1063
  res.writeHead(204, {
@@ -2889,6 +2929,8 @@ function createServer(opts) {
2889
2929
  osUsers: osUsers,
2890
2930
  currentVersion: currentVersion,
2891
2931
  lanHost: lanHost,
2932
+ port: portNum,
2933
+ tls: !!tlsOptions,
2892
2934
  getProjectCount: function () { return projects.size; },
2893
2935
  getProjectList: function (userId) {
2894
2936
  var list = [];
package/lib/sessions.js CHANGED
@@ -1,5 +1,6 @@
1
1
  var fs = require("fs");
2
2
  var path = require("path");
3
+ var crypto = require("crypto");
3
4
  var config = require("./config");
4
5
  var utils = require("./utils");
5
6
  var users = require("./users");
@@ -75,7 +76,9 @@ function createSessionManager(opts) {
75
76
  }
76
77
 
77
78
  function saveSessionFile(session) {
78
- if (!session.cliSessionId) return;
79
+ if (!session.cliSessionId) {
80
+ session.cliSessionId = crypto.randomUUID();
81
+ }
79
82
  try {
80
83
  var metaObj = {
81
84
  type: "meta",
@@ -96,17 +99,24 @@ function createSessionManager(opts) {
96
99
  lines.push(JSON.stringify(session.history[i]));
97
100
  }
98
101
  var sfPath = sessionFilePath(session.cliSessionId);
99
- fs.writeFileSync(sfPath, lines.join("\n") + "\n");
102
+ // Atomic write: write to temp file then rename, so a crash mid-write
103
+ // cannot leave a truncated/corrupted session file.
104
+ var tmpPath = sfPath + ".tmp." + process.pid;
105
+ fs.writeFileSync(tmpPath, lines.join("\n") + "\n");
100
106
  if (process.platform !== "win32") {
101
- try { fs.chmodSync(sfPath, 0o600); } catch (chmodErr) {}
107
+ try { fs.chmodSync(tmpPath, 0o600); } catch (chmodErr) {}
102
108
  }
109
+ fs.renameSync(tmpPath, sfPath);
103
110
  } catch(e) {
104
111
  console.error("[session] Failed to save session file:", e.message);
105
112
  }
106
113
  }
107
114
 
108
115
  function appendToSessionFile(session, obj) {
109
- if (!session.cliSessionId) return;
116
+ if (!session.cliSessionId) {
117
+ session.cliSessionId = crypto.randomUUID();
118
+ saveSessionFile(session);
119
+ }
110
120
  session.lastActivity = Date.now();
111
121
  try {
112
122
  var afPath = sessionFilePath(session.cliSessionId);
@@ -123,6 +133,13 @@ function createSessionManager(opts) {
123
133
  var files;
124
134
  try { files = fs.readdirSync(sessionsDir); } catch { return; }
125
135
 
136
+ // Clean up stale temp files from interrupted atomic writes
137
+ for (var ti = 0; ti < files.length; ti++) {
138
+ if (files[ti].indexOf(".tmp.") !== -1) {
139
+ try { fs.unlinkSync(path.join(sessionsDir, files[ti])); } catch (e) {}
140
+ }
141
+ }
142
+
126
143
  var loaded = [];
127
144
  for (var i = 0; i < files.length; i++) {
128
145
  if (!files[i].endsWith(".jsonl")) continue;
@@ -226,10 +243,8 @@ function createSessionManager(opts) {
226
243
  return [...sessions.values()].filter(function (s) {
227
244
  if (s.hidden) return false;
228
245
  if (!multiUser) {
229
- // Single-user mode: only show sessions without ownerId
230
246
  return !s.ownerId;
231
247
  }
232
- // Multi-user mode: include all sessions (per-user filtering done by canAccessSession)
233
248
  return true;
234
249
  });
235
250
  }
@@ -355,7 +370,7 @@ function createSessionManager(opts) {
355
370
  }
356
371
  }
357
372
 
358
- _send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens });
373
+ _send({ type: "history_done", lastUsage: lastUsage, lastModelUsage: lastModelUsage, lastCost: lastCost, lastStreamInputTokens: lastStreamInputTokens, contextUsage: session.lastContextUsage || null });
359
374
  }
360
375
 
361
376
  function switchSession(localId, targetWs, transform) {
@@ -481,7 +496,23 @@ function createSessionManager(opts) {
481
496
  sessions.delete(localId);
482
497
  }
483
498
 
499
+ function doSendToSession(session, obj) {
500
+ // Send to active clients without recording to history/disk (ephemeral data)
501
+ if (sendEach) {
502
+ var data = JSON.stringify(obj);
503
+ sendEach(function (ws) {
504
+ if (ws._clayActiveSession === session.localId && ws.readyState === 1) {
505
+ ws.send(data);
506
+ }
507
+ });
508
+ } else if (session.localId === activeSessionId) {
509
+ send(obj);
510
+ }
511
+ }
512
+
484
513
  function doSendAndRecord(session, obj) {
514
+ // Stamp every recorded message so history replay preserves original times
515
+ if (!obj._ts) obj._ts = Date.now();
485
516
  session.history.push(obj);
486
517
  appendToSessionFile(session, obj);
487
518
  if (sendEach) {
@@ -726,6 +757,7 @@ function createSessionManager(opts) {
726
757
  saveSessionFile: saveSessionFile,
727
758
  appendToSessionFile: appendToSessionFile,
728
759
  sendAndRecord: doSendAndRecord,
760
+ sendToSession: doSendToSession,
729
761
  findTurnBoundary: findTurnBoundary,
730
762
  replayHistory: replayHistory,
731
763
  searchSessions: searchSessions,
@@ -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);
@@ -28,20 +28,24 @@ 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,
34
35
  exited: false,
35
36
  exitCode: null,
36
37
  subscribers: new Set(),
38
+ ownerWs: ownerWs || null,
37
39
  };
38
40
 
39
41
  pty.onData(function (data) {
40
- // Buffer scrollback
41
- session.scrollback.push(data);
42
+ // Buffer scrollback with timestamps
43
+ var ts = Date.now();
44
+ session.scrollback.push({ ts: ts, data: data });
42
45
  session.scrollbackSize += data.length;
46
+ session.totalBytesWritten += data.length;
43
47
  while (session.scrollbackSize > SCROLLBACK_MAX && session.scrollback.length > 1) {
44
- session.scrollbackSize -= session.scrollback[0].length;
48
+ session.scrollbackSize -= session.scrollback[0].data.length;
45
49
  session.scrollback.shift();
46
50
  }
47
51
 
@@ -80,10 +84,15 @@ function createTerminalManager(opts) {
80
84
 
81
85
  // Replay scrollback only for newly attached clients
82
86
  if (!alreadySubscribed && session.scrollback.length > 0) {
83
- var replay = session.scrollback.join("");
87
+ var replay = session.scrollback.map(function(c) { return c.data; }).join("");
84
88
  sendTo(ws, { type: "term_output", id: id, data: replay });
85
89
  }
86
90
 
91
+ // Send current terminal dimensions so the client renders at the correct size
92
+ if (!alreadySubscribed && session.cols && session.rows) {
93
+ sendTo(ws, { type: "term_resized", id: id, cols: session.cols, rows: session.rows });
94
+ }
95
+
87
96
  // If already exited, notify
88
97
  if (session.exited) {
89
98
  sendTo(ws, { type: "term_exited", id: id });
@@ -111,14 +120,22 @@ function createTerminalManager(opts) {
111
120
  }
112
121
  }
113
122
 
114
- function resize(id, cols, rows) {
123
+ function resize(id, cols, rows, sourceWs) {
115
124
  var session = terminals.get(id);
116
125
  if (!session || !session.pty) return;
126
+ // Only the terminal owner can resize the PTY.
127
+ // Observers resizing would cause SIGWINCH and flood the owner with escape sequences.
128
+ if (session.ownerWs && sourceWs && sourceWs !== session.ownerWs) return;
117
129
  if (cols > 0 && rows > 0) {
118
130
  try {
119
131
  session.pty.resize(cols, rows);
120
132
  session.cols = cols;
121
133
  session.rows = rows;
134
+ // Notify other subscribers about the resize so their xterm stays in sync
135
+ var msg = JSON.stringify({ type: "term_resized", id: id, cols: cols, rows: rows });
136
+ for (var ws of session.subscribers) {
137
+ if (ws.readyState === 1 && ws !== sourceWs) ws.send(msg);
138
+ }
122
139
  } catch (e) {}
123
140
  }
124
141
  }
@@ -162,6 +179,18 @@ function createTerminalManager(opts) {
162
179
  return result;
163
180
  }
164
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
+
165
194
  function destroyAll() {
166
195
  for (var session of terminals.values()) {
167
196
  if (session.pty) {
@@ -182,6 +211,7 @@ function createTerminalManager(opts) {
182
211
  close: close,
183
212
  rename: rename,
184
213
  list: list,
214
+ getScrollback: getScrollback,
185
215
  destroyAll: destroyAll,
186
216
  };
187
217
  }
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.10",
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",
@@ -36,7 +36,7 @@
36
36
  "homepage": "https://github.com/chadbyte/claude-relay#readme",
37
37
  "author": "Chad",
38
38
  "dependencies": {
39
- "@anthropic-ai/claude-agent-sdk": "^0.2.76",
39
+ "@anthropic-ai/claude-agent-sdk": "^0.2.92",
40
40
  "@lydell/node-pty": "^1.2.0-beta.3",
41
41
  "nodemailer": "^6.10.1",
42
42
  "qrcode-terminal": "^0.12.0",