clay-server 2.39.0-beta.3 → 2.39.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/daemon.js CHANGED
@@ -1248,11 +1248,114 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1248
1248
  }
1249
1249
  });
1250
1250
 
1251
+ // --- Cleanup stale SDK warmup CLI session files ---
1252
+ //
1253
+ // On adapter init the SDK is given a dummy "hi" prompt to discover models,
1254
+ // skills, and capabilities. Even though we abort before any response
1255
+ // arrives, the SDK creates a real CLI session jsonl on disk. Going forward
1256
+ // we delete those inline (see lib/yoke/adapters/claude.js and
1257
+ // claude-worker.js), but installs from before that fix can have a long
1258
+ // tail of "hi"-only sessions polluting the Resume CLI session dialog.
1259
+ //
1260
+ // On every daemon startup we scan REAL_HOME/.claude/projects/*/ and unlink
1261
+ // files that match a strict warmup signature so legitimate "hi" sessions
1262
+ // from real users are never touched. All checks must pass:
1263
+ //
1264
+ // 1. file size < 64 KB (real sessions grow past this fast;
1265
+ // threshold is generous because warmup files include the SDK's
1266
+ // skill_listing / deferred_tools attachments which can run into the
1267
+ // tens of KB on its own)
1268
+ // 2. mtime older than 60 seconds (avoid racing an in-flight warmup)
1269
+ // 3. exactly one user message, content text equals literal "hi"
1270
+ // 4. zero assistant messages (real users virtually always get a reply)
1271
+ //
1272
+ // Multi-user OS-isolated installs have per-user ~/.claude trees that the
1273
+ // daemon process can't traverse; the inline cleanup in claude-worker.js
1274
+ // covers those.
1275
+ function cleanupSdkWarmupSessions() {
1276
+ var projectsRoot = path.join(REAL_HOME, ".claude", "projects");
1277
+ var projectDirs;
1278
+ try { projectDirs = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
1279
+ catch (e) { return; }
1280
+
1281
+ var deleted = 0;
1282
+ var nowMs = Date.now();
1283
+ var MAX_BYTES = 64 * 1024;
1284
+ var MIN_AGE_MS = 60 * 1000;
1285
+
1286
+ for (var di = 0; di < projectDirs.length; di++) {
1287
+ if (!projectDirs[di].isDirectory()) continue;
1288
+ var projDir = path.join(projectsRoot, projectDirs[di].name);
1289
+ var files;
1290
+ try { files = fs.readdirSync(projDir); } catch (e) { continue; }
1291
+
1292
+ for (var fi = 0; fi < files.length; fi++) {
1293
+ var f = files[fi];
1294
+ if (!f.endsWith(".jsonl")) continue;
1295
+ var fp = path.join(projDir, f);
1296
+
1297
+ var st;
1298
+ try { st = fs.statSync(fp); } catch (e) { continue; }
1299
+ if (st.size >= MAX_BYTES) continue;
1300
+ if (nowMs - st.mtimeMs < MIN_AGE_MS) continue;
1301
+
1302
+ var raw;
1303
+ try { raw = fs.readFileSync(fp, "utf8"); } catch (e) { continue; }
1304
+ var lines = raw.split("\n");
1305
+ var userCount = 0;
1306
+ var assistantCount = 0;
1307
+ var userText = null;
1308
+ var parseOk = true;
1309
+ for (var li = 0; li < lines.length; li++) {
1310
+ var line = lines[li];
1311
+ if (!line) continue;
1312
+ var ev;
1313
+ try { ev = JSON.parse(line); } catch (e) { parseOk = false; break; }
1314
+ if (!ev || typeof ev !== "object") continue;
1315
+ if (ev.type === "user") {
1316
+ userCount++;
1317
+ if (userCount > 1) break;
1318
+ var msg = ev.message;
1319
+ var content = msg && msg.content;
1320
+ if (typeof content === "string") {
1321
+ userText = content;
1322
+ } else if (Array.isArray(content)) {
1323
+ var parts = [];
1324
+ for (var ci = 0; ci < content.length; ci++) {
1325
+ if (content[ci] && content[ci].type === "text" && typeof content[ci].text === "string") {
1326
+ parts.push(content[ci].text);
1327
+ }
1328
+ }
1329
+ userText = parts.join("");
1330
+ }
1331
+ } else if (ev.type === "assistant") {
1332
+ assistantCount++;
1333
+ if (assistantCount > 0) break;
1334
+ }
1335
+ }
1336
+ if (!parseOk) continue;
1337
+ if (userCount !== 1) continue;
1338
+ if (assistantCount !== 0) continue;
1339
+ if (userText !== "hi") continue;
1340
+
1341
+ try { fs.unlinkSync(fp); deleted++; } catch (e) {}
1342
+ }
1343
+ }
1344
+
1345
+ if (deleted > 0) {
1346
+ console.log("[daemon] Removed " + deleted + " stale SDK warmup session file(s)");
1347
+ }
1348
+ }
1349
+
1251
1350
  // --- Start listening (with retry for port-in-use during update handoff) ---
1252
1351
  var listenRetries = 0;
1253
1352
  var MAX_LISTEN_RETRIES = 15;
1254
1353
 
1255
1354
  function startListening() {
1355
+ // One-shot cleanup of stale SDK warmup CLI session files from previous
1356
+ // daemon runs (best-effort, never fatal).
1357
+ try { cleanupSdkWarmupSessions(); } catch (e) {}
1358
+
1256
1359
  relay.server.listen(config.port, listenHost, function () {
1257
1360
  var protocol = tlsOptions ? "https" : "http";
1258
1361
  console.log("[daemon] Listening on " + protocol + "://" + listenHost + ":" + config.port);
@@ -265,12 +265,21 @@ function attachSessions(ctx) {
265
265
  return null;
266
266
  }
267
267
 
268
- // In-place conversion of a TUI session to GUI when the user's preference
269
- // is 'gui'. Reads the jsonl transcript through the existing import path
270
- // (same code Import CLI uses), populates session.history, flips the mode
271
- // to 'gui', and kills the PTY. Subsequent clicks render via the SDK chat.
272
- function convertTuiSessionToGui(session) {
268
+ // Prepare a born-TUI session to be rendered via the SDK GUI chat for the
269
+ // current click. Reads the CLI jsonl transcript (cli-sessions.js),
270
+ // populates session.history, and tears
271
+ // down the PTY. The born-TUI marker (session.mode === 'tui') is kept on
272
+ // disk so that flipping claudeOpenMode back to 'tui' later restores the
273
+ // embedded-terminal experience instead of locking the session as GUI.
274
+ //
275
+ // Idempotent: if the PTY is already gone and history is populated, this
276
+ // is a no-op (avoids re-reading jsonl and re-writing the session file on
277
+ // every click while the user is reading the session in GUI mode).
278
+ function prepareTuiSessionForGuiView(session) {
273
279
  if (!session || session.cliSessionId == null) return;
280
+ var alreadyHydrated = (typeof session.terminalId !== "number") &&
281
+ Array.isArray(session.history) && session.history.length > 0;
282
+ if (alreadyHydrated) return;
274
283
  var cliSess;
275
284
  try { cliSess = require("./cli-sessions"); } catch (e) { return; }
276
285
  var history = null;
@@ -278,7 +287,6 @@ function attachSessions(ctx) {
278
287
  if (Array.isArray(history)) {
279
288
  session.history = history;
280
289
  }
281
- session.mode = "gui";
282
290
  if (typeof session.terminalId === "number" && tm) {
283
291
  try { tm.close(session.terminalId); } catch (e) {}
284
292
  }
@@ -494,101 +502,10 @@ function attachSessions(ctx) {
494
502
  return true;
495
503
  }
496
504
 
497
- if (msg.type === "resume_session") {
498
- if (!msg.cliSessionId) return true;
499
- var cliSess = require("./cli-sessions");
500
-
501
- // If Clay already has a persisted meta file for this cliSessionId, read
502
- // its vendor so resumeSession doesn't silently default to the project's
503
- // primary vendor (which would break codex sessions after server restart).
504
- var persistedVendor = null;
505
- try {
506
- var _fsResume = require("fs");
507
- var _pathResume = require("path");
508
- var metaPath = _pathResume.join(sm.sessionsDir, msg.cliSessionId + ".jsonl");
509
- if (_fsResume.existsSync(metaPath)) {
510
- var firstLine = _fsResume.readFileSync(metaPath, "utf8").split("\n", 1)[0];
511
- try {
512
- var metaObj = JSON.parse(firstLine);
513
- if (metaObj && metaObj.type === "meta" && metaObj.vendor) persistedVendor = metaObj.vendor;
514
- } catch (e) {}
515
- }
516
- } catch (e) {}
517
-
518
- // Try SDK for title first, then fall back to manual parsing
519
- var titlePromise = adapter.getSessionInfo(msg.cliSessionId, { dir: cwd }).then(function(info) {
520
- return (info && info.summary) ? info.summary.substring(0, 100) : null;
521
- }).catch(function() { return null; });
522
-
523
- Promise.all([
524
- cliSess.readCliSessionHistory(cwd, msg.cliSessionId),
525
- titlePromise
526
- ]).then(function(results) {
527
- var history = results[0];
528
- var sdkTitle = results[1];
529
- var title = sdkTitle || "Resumed session";
530
- if (!sdkTitle) {
531
- for (var i = 0; i < history.length; i++) {
532
- if (history[i].type === "user_message" && history[i].text) {
533
- title = history[i].text.substring(0, 50);
534
- break;
535
- }
536
- }
537
- }
538
- var resumed = sm.resumeSession(msg.cliSessionId, { history: history, title: title, vendor: persistedVendor || undefined }, ws);
539
- if (resumed) ws._clayActiveSession = resumed.localId;
540
- }).catch(function() {
541
- var resumed = sm.resumeSession(msg.cliSessionId, persistedVendor ? { vendor: persistedVendor } : undefined, ws);
542
- if (resumed) ws._clayActiveSession = resumed.localId;
543
- });
544
- return true;
545
- }
546
-
547
- if (msg.type === "list_cli_sessions") {
548
- var _fs = require("fs");
549
- // Collect session IDs already in relay (in-memory + persisted on disk)
550
- var relayIds = {};
551
- sm.sessions.forEach(function (s) {
552
- if (s.cliSessionId) relayIds[s.cliSessionId] = true;
553
- });
554
- try {
555
- var sessDir = sm.sessionsDir;
556
- var diskFiles = _fs.readdirSync(sessDir);
557
- for (var fi = 0; fi < diskFiles.length; fi++) {
558
- if (diskFiles[fi].endsWith(".jsonl")) {
559
- relayIds[diskFiles[fi].replace(".jsonl", "")] = true;
560
- }
561
- }
562
- } catch (e) {}
563
-
564
- adapter.listSessions({ dir: cwd }).then(function(sdkSessions) {
565
- var filtered = sdkSessions.filter(function(s) {
566
- return !relayIds[s.sessionId];
567
- }).map(function(s) {
568
- return {
569
- sessionId: s.sessionId,
570
- firstPrompt: s.summary || s.firstPrompt || "",
571
- model: null,
572
- gitBranch: s.gitBranch || null,
573
- startTime: s.createdAt ? new Date(s.createdAt).toISOString() : null,
574
- lastActivity: s.lastModified ? new Date(s.lastModified).toISOString() : null,
575
- };
576
- });
577
- sendTo(ws, { type: "cli_session_list", sessions: filtered });
578
- }).catch(function() {
579
- // Fallback to manual parsing if SDK fails
580
- var cliSessions = require("./cli-sessions");
581
- cliSessions.listCliSessions(cwd).then(function(sessions) {
582
- var filtered = sessions.filter(function(s) {
583
- return !relayIds[s.sessionId];
584
- });
585
- sendTo(ws, { type: "cli_session_list", sessions: filtered });
586
- }).catch(function() {
587
- sendTo(ws, { type: "cli_session_list", sessions: [] });
588
- });
589
- });
590
- return true;
591
- }
505
+ // Note: the old `resume_session` and `list_cli_sessions` WS handlers
506
+ // were removed when CLI sessions started being auto-adopted into the
507
+ // unified session list on server start (see sessions.js
508
+ // adoptOrphanedCliSessions). Click a session in the sidebar instead.
592
509
 
593
510
  if (msg.type === "switch_session") {
594
511
  if (msg.id && sm.sessions.has(msg.id)) {
@@ -598,21 +515,24 @@ function attachSessions(ctx) {
598
515
  // - born-GUI viewed under TUI pref: spawn a transient PTY running
599
516
  // `claude --resume <cliSessionId>`; the session record stays GUI
600
517
  // so a later pref flip back to GUI just hides the runtime link.
601
- // - born-TUI viewed under GUI pref: in-place convert via the
602
- // Import CLI code path (jsonl history -> session.history,
603
- // session.mode -> 'gui', kill PTY). This is destructive but
604
- // matches "TUI->GUI is what Import CLI was always for."
518
+ // - born-TUI viewed under GUI pref: hydrate session.history from
519
+ // the jsonl transcript and tear down the PTY so the session
520
+ // renders via the SDK chat. The born-TUI marker stays on the
521
+ // record so a later pref flip back to TUI restores the
522
+ // embedded-terminal experience.
605
523
  //
606
524
  // runtimeMode / runtimeTerminalId are set on the session record so
607
525
  // the session_switched and session_list broadcasts surface them to
608
526
  // the client without sessions.js needing to know about the pref.
609
527
  var xmTarget = sm.sessions.get(msg.id);
610
528
  if (xmTarget && (xmTarget.vendor === "claude" || !xmTarget.vendor)) {
529
+ var xmPref = getClaudeOpenModeForWs(ws);
611
530
  // Born-TUI session whose PTY is gone (typically after a daemon
612
- // restart). Respawn via `claude --resume <cliSessionId>` before
613
- // the cross-mode logic runs - the rest of the pipeline assumes
614
- // terminalId points at a live PTY for TUI sessions.
615
- if (xmTarget.mode === "tui" && xmTarget.cliSessionId && tm &&
531
+ // restart) AND the user wants TUI rendering this click. Respawn
532
+ // via `claude --resume <cliSessionId>` so the rest of the pipeline
533
+ // has a live PTY to point at. Skipped when the user's pref is GUI
534
+ // - we'd just tear it back down two lines later.
535
+ if (xmTarget.mode === "tui" && xmTarget.cliSessionId && tm && xmPref === "tui" &&
616
536
  (typeof xmTarget.terminalId !== "number" || !tm.has(xmTarget.terminalId))) {
617
537
  var rsSid = xmTarget.cliSessionId;
618
538
  var rsLocalId = xmTarget.localId;
@@ -632,11 +552,15 @@ function attachSessions(ctx) {
632
552
  if (rsTerm) xmTarget.terminalId = rsTerm.id;
633
553
  startTitleWatcher(xmTarget);
634
554
  }
635
- var xmPref = getClaudeOpenModeForWs(ws);
636
555
  var xmRuntime = computeRuntimeMode(xmTarget, xmPref);
637
556
  if (xmRuntime === "gui" && xmTarget.mode === "tui") {
638
- convertTuiSessionToGui(xmTarget);
639
- xmTarget.runtimeMode = null;
557
+ // Born-TUI session under GUI pref. Hydrate history + drop PTY
558
+ // without flipping session.mode, so flipping back to TUI later
559
+ // restores the embedded-terminal rendering. runtimeMode is set
560
+ // to 'gui' explicitly so the client doesn't fall back to
561
+ // session.mode (which is still 'tui').
562
+ prepareTuiSessionForGuiView(xmTarget);
563
+ xmTarget.runtimeMode = "gui";
640
564
  xmTarget.runtimeTerminalId = null;
641
565
  } else if (xmRuntime === "tui" && xmTarget.mode === "gui" && xmTarget.cliSessionId) {
642
566
  var xmRid = spawnRuntimeTuiPty(xmTarget, ws);
package/lib/public/app.js CHANGED
@@ -5,7 +5,7 @@ import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks, closeMermaidM
5
5
  import { initSidebar, updatePageTitle, spawnDustParticles } from './modules/sidebar.js';
6
6
  import {
7
7
  renderSessionList, handleSearchResults, updateSessionPresence,
8
- updateSessionBadge, populateCliSessionList
8
+ updateSessionBadge
9
9
  } from './modules/sidebar-sessions.js';
10
10
  import {
11
11
  renderIconStrip, getEmojiCategories, updateProjectBadge
@@ -99,7 +99,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
99
99
  var hamburgerBtn = $("hamburger-btn");
100
100
  var sidebarToggleBtn = $("sidebar-toggle-btn");
101
101
  var sidebarExpandBtn = $("sidebar-expand-btn");
102
- var resumeSessionBtn = $("resume-session-btn");
103
102
  var imagePreviewBar = $("image-preview-bar");
104
103
  var connectOverlay = $("connect-overlay");
105
104
 
@@ -378,7 +377,6 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
378
377
  sidebarExpandBtn: sidebarExpandBtn,
379
378
  hamburgerBtn: hamburgerBtn,
380
379
  newSessionBtn: newSessionBtn,
381
- resumeSessionBtn: resumeSessionBtn,
382
380
  headerTitleEl: headerTitleEl,
383
381
  showConfirm: showConfirm,
384
382
  onFilesTabOpen: function () { loadRootDirectory(); },
@@ -1556,26 +1556,6 @@
1556
1556
  </div>
1557
1557
  </div>
1558
1558
 
1559
- <div id="resume-modal" class="hidden">
1560
- <div class="confirm-backdrop"></div>
1561
- <div class="confirm-dialog resume-picker-dialog">
1562
- <div class="resume-modal-title">Resume CLI session</div>
1563
- <div class="resume-picker-body">
1564
- <div id="resume-picker-loading" class="resume-picker-loading">
1565
- <div class="resume-picker-spinner"></div>
1566
- <span>Loading sessions...</span>
1567
- </div>
1568
- <div id="resume-picker-empty" class="resume-picker-empty hidden">
1569
- No CLI sessions found for this project.
1570
- </div>
1571
- <div id="resume-picker-list" class="resume-picker-list hidden"></div>
1572
- </div>
1573
- <div class="confirm-actions">
1574
- <button class="confirm-btn confirm-cancel" id="resume-cancel">Cancel</button>
1575
- </div>
1576
- </div>
1577
- </div>
1578
-
1579
1559
  <div id="rewind-modal" class="hidden">
1580
1560
  <div class="confirm-backdrop"></div>
1581
1561
  <div class="confirm-dialog">
@@ -10,7 +10,7 @@ import { showToast } from './utils.js';
10
10
  import { refreshIcons, iconHtml } from './icons.js';
11
11
  import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks } from './markdown.js';
12
12
  import { updatePageTitle } from './sidebar.js';
13
- import { renderSessionList, updateSessionPresence, populateCliSessionList, handleSearchResults, updateSessionBadge } from './sidebar-sessions.js';
13
+ import { renderSessionList, updateSessionPresence, handleSearchResults, updateSessionBadge } from './sidebar-sessions.js';
14
14
  import { updateDmBadge, renderSidebarPresence, setMentionActive, renderUserStrip } from './sidebar-mates.js';
15
15
  import { refreshMobileChatSheet } from './sidebar-mobile.js';
16
16
  import { renderMateSessionList, handleMateSearchResults, updateMateSidebarProfile } from './mate-sidebar.js';
@@ -585,10 +585,6 @@ export function processMessage(msg) {
585
585
  }
586
586
  break;
587
587
 
588
- case "cli_session_list":
589
- populateCliSessionList(msg.sessions || []);
590
- break;
591
-
592
588
  case "session_switched":
593
589
  hideHomeHub();
594
590
  closeWhatsNewArticle();
@@ -15,6 +15,7 @@
15
15
  // the new session list; this view tears itself down via handleTermExited.
16
16
 
17
17
  import { getWs } from './ws-ref.js';
18
+ import { store } from './store.js';
18
19
  import { openArticle as openWhatsNewArticle } from './whats-new-article.js';
19
20
 
20
21
  // Stable id of the canonical "Why TUI mode?" article in
@@ -164,10 +165,91 @@ function ensureHostEl() {
164
165
  xtermContainerEl.style.position = "relative";
165
166
  hostEl.appendChild(xtermContainerEl);
166
167
 
168
+ // Paste-image handling. xterm.js natively pastes text but ignores
169
+ // image/file clipboard payloads, so an image copied in Finder/Preview
170
+ // would silently drop on Cmd+V. We intercept in the capture phase
171
+ // (before xterm's hidden textarea handles the event), upload the
172
+ // image to /api/upload, and inject the returned absolute path into
173
+ // the PTY. Claude CLI accepts file paths as prompt input - the user
174
+ // can then submit with Enter.
175
+ //
176
+ // Capture phase + stopImmediatePropagation on image hits is what
177
+ // keeps xterm from ALSO pasting the file's text representation
178
+ // (Finder copies leave a "filename.png" string in text/plain).
179
+ // Text-only paste falls through so xterm's built-in text paste keeps
180
+ // working unchanged.
181
+ hostEl.addEventListener("paste", handleTuiPaste, true);
182
+
167
183
  document.body.appendChild(hostEl);
168
184
  return hostEl;
169
185
  }
170
186
 
187
+ function handleTuiPaste(e) {
188
+ var cd = e.clipboardData;
189
+ if (!cd || currentTermId == null) return;
190
+
191
+ // Collect image blobs from both cd.files (Safari/iOS friendly) and
192
+ // cd.items. Plain-text-only paste is left untouched so xterm's built-in
193
+ // text paste keeps working.
194
+ var blobs = [];
195
+ if (cd.files && cd.files.length > 0) {
196
+ for (var i = 0; i < cd.files.length; i++) {
197
+ if (cd.files[i] && cd.files[i].type.indexOf("image/") === 0) blobs.push(cd.files[i]);
198
+ }
199
+ }
200
+ if (blobs.length === 0 && cd.items) {
201
+ for (var j = 0; j < cd.items.length; j++) {
202
+ if (cd.items[j] && cd.items[j].type && cd.items[j].type.indexOf("image/") === 0) {
203
+ var b = cd.items[j].getAsFile();
204
+ if (b) blobs.push(b);
205
+ }
206
+ }
207
+ }
208
+ if (blobs.length === 0) return; // text or other - let xterm handle
209
+
210
+ // Stop xterm's textarea from also seeing this event and pasting the
211
+ // file's text representation (filename) alongside our injected path.
212
+ e.preventDefault();
213
+ e.stopImmediatePropagation();
214
+ for (var k = 0; k < blobs.length; k++) {
215
+ uploadAndInjectPath(blobs[k]);
216
+ }
217
+ }
218
+
219
+ function uploadAndInjectPath(blob) {
220
+ var ext = ".png";
221
+ if (blob.type === "image/jpeg") ext = ".jpg";
222
+ else if (blob.type === "image/gif") ext = ".gif";
223
+ else if (blob.type === "image/webp") ext = ".webp";
224
+ var name = blob.name || ("pasted-" + Date.now() + ext);
225
+
226
+ var reader = new FileReader();
227
+ reader.onload = function (ev) {
228
+ var dataUrl = ev.target.result;
229
+ var commaIdx = dataUrl.indexOf(",");
230
+ var b64 = commaIdx !== -1 ? dataUrl.substring(commaIdx + 1) : "";
231
+ var basePath = store.get("basePath") || "/";
232
+ var xhr = new XMLHttpRequest();
233
+ xhr.open("POST", basePath + "api/upload");
234
+ xhr.setRequestHeader("Content-Type", "application/json");
235
+ xhr.onload = function () {
236
+ if (xhr.status !== 200) return;
237
+ try {
238
+ var resp = JSON.parse(xhr.responseText);
239
+ if (!resp || !resp.path || currentTermId == null) return;
240
+ var ws = getWs();
241
+ if (!ws || ws.readyState !== 1) return;
242
+ // Wrap in double quotes if the path contains whitespace so the
243
+ // shell / claude CLI treats it as a single token.
244
+ var injected = /\s/.test(resp.path) ? '"' + resp.path + '"' : resp.path;
245
+ ws.send(JSON.stringify({ type: "term_input", id: currentTermId, data: injected }));
246
+ } catch (e) {}
247
+ };
248
+ xhr.send(JSON.stringify({ name: name, data: b64 }));
249
+ };
250
+ reader.readAsDataURL(blob);
251
+ }
252
+
171
253
  function syncHostBounds() {
172
254
  if (!hostEl) return;
173
255
  var messagesEl = document.getElementById("messages");
@@ -11,8 +11,7 @@ import { getMateSessions } from './mate-sidebar.js';
11
11
  import { openProjectSettings } from './project-settings.js';
12
12
  import {
13
13
  getCachedSessions,
14
- getDateGroup,
15
- openResumePicker
14
+ getDateGroup
16
15
  } from './sidebar-sessions.js';
17
16
  import {
18
17
  getCachedProjectList,
@@ -710,15 +709,6 @@ function renderMobileSessionsInto(container) {
710
709
  });
711
710
  container.appendChild(newBtn);
712
711
 
713
- var importBtn = document.createElement("button");
714
- importBtn.className = "mobile-session-new";
715
- importBtn.innerHTML = '<i data-lucide="import" style="width:16px;height:16px"></i> Import session';
716
- importBtn.addEventListener("click", function () {
717
- closeMobileSheet();
718
- setTimeout(function () { openResumePicker(); }, 250);
719
- });
720
- container.appendChild(importBtn);
721
-
722
712
  // Partition: loop sessions vs normal sessions (same logic as desktop renderSessionList)
723
713
  var sessions = getCachedSessions();
724
714
  var loopGroups = {};
@@ -34,15 +34,10 @@ var sessionCtxMenu = null;
34
34
  var sessionCtxSessionId = null;
35
35
  var draggedSessionId = null;
36
36
  var draggedSessionBookmarked = false;
37
- var openResumePickerModal = function () {};
38
37
  var headerSearchOpen = false;
39
38
  var armedDeleteSessionId = null;
40
39
  var armedDeleteTimer = null;
41
40
 
42
- export function openResumePicker() {
43
- openResumePickerModal();
44
- }
45
-
46
41
  function sendSessionBookmark(sessionId, bookmarked) {
47
42
  if (getWs() && store.get('connected')) {
48
43
  getWs().send(JSON.stringify({ type: "set_session_bookmark", sessionId: sessionId, bookmarked: !!bookmarked }));
@@ -311,10 +306,13 @@ function openClaudeModeMenu(x, y) {
311
306
  closeClaudeModeMenu();
312
307
  var menu = document.createElement("div");
313
308
  menu.className = "claude-mode-menu";
309
+ // CLI sessions (created by the `claude` CLI outside Clay) are now
310
+ // auto-adopted into the regular session list on server start, so no
311
+ // separate "Import CLI" entry is needed. Users see all sessions as one
312
+ // list and click renders per their claudeOpenMode pref.
314
313
  var items = [
315
314
  { label: "Start as TUI", hint: "Real claude terminal", action: "new", mode: "tui" },
316
315
  { label: "Start as GUI", hint: "Clay chat UI", action: "new", mode: "gui" },
317
- { label: "Import CLI", hint: "Resume an existing claude session", action: "import" },
318
316
  ];
319
317
  for (var i = 0; i < items.length; i++) {
320
318
  (function (it) {
@@ -326,9 +324,7 @@ function openClaudeModeMenu(x, y) {
326
324
  b.addEventListener("click", function (e) {
327
325
  e.stopPropagation();
328
326
  closeClaudeModeMenu();
329
- if (it.action === "import") {
330
- openResumePickerModal();
331
- } else if (getWs() && store.get('connected')) {
327
+ if (getWs() && store.get('connected')) {
332
328
  getWs().send(JSON.stringify({ type: "new_session", vendor: "claude", mode: it.mode }));
333
329
  }
334
330
  });
@@ -539,31 +535,6 @@ export function initSidebarSessions() {
539
535
  }
540
536
 
541
537
  // --- Resume session picker ---
542
- var resumeModal = document.getElementById("resume-modal");
543
- var resumeCancel = document.getElementById("resume-cancel");
544
- var pickerLoading = document.getElementById("resume-picker-loading");
545
- var pickerEmpty = document.getElementById("resume-picker-empty");
546
- var pickerList = document.getElementById("resume-picker-list");
547
-
548
- function openResumeModal() {
549
- resumeModal.classList.remove("hidden");
550
- pickerLoading.classList.remove("hidden");
551
- pickerEmpty.classList.add("hidden");
552
- pickerList.classList.add("hidden");
553
- pickerList.innerHTML = "";
554
- if (getWs() && store.get('connected')) {
555
- getWs().send(JSON.stringify({ type: "list_cli_sessions" }));
556
- }
557
- }
558
- openResumePickerModal = openResumeModal;
559
-
560
- function closeResumeModal() {
561
- resumeModal.classList.add("hidden");
562
- }
563
-
564
- resumeCancel.addEventListener("click", closeResumeModal);
565
- resumeModal.querySelector(".confirm-backdrop").addEventListener("click", closeResumeModal);
566
-
567
538
  // --- Schedule countdown timer ---
568
539
  startCountdownTimer();
569
540
  }
@@ -1450,66 +1421,3 @@ function relativeTime(isoString) {
1450
1421
  return new Date(isoString).toLocaleDateString();
1451
1422
  }
1452
1423
 
1453
- export function populateCliSessionList(sessions) {
1454
- var pickerLoading = document.getElementById("resume-picker-loading");
1455
- var pickerEmpty = document.getElementById("resume-picker-empty");
1456
- var pickerList = document.getElementById("resume-picker-list");
1457
- if (!pickerLoading || !pickerList) return;
1458
-
1459
- pickerLoading.classList.add("hidden");
1460
-
1461
- if (!sessions || sessions.length === 0) {
1462
- pickerEmpty.classList.remove("hidden");
1463
- pickerList.classList.add("hidden");
1464
- return;
1465
- }
1466
-
1467
- pickerEmpty.classList.add("hidden");
1468
- pickerList.classList.remove("hidden");
1469
- pickerList.innerHTML = "";
1470
-
1471
- for (var i = 0; i < sessions.length; i++) {
1472
- var s = sessions[i];
1473
- var item = document.createElement("div");
1474
- item.className = "cli-session-item";
1475
-
1476
- var title = document.createElement("div");
1477
- title.className = "cli-session-title";
1478
- title.textContent = s.firstPrompt || "Untitled session";
1479
- item.appendChild(title);
1480
-
1481
- var meta = document.createElement("div");
1482
- meta.className = "cli-session-meta";
1483
- if (s.lastActivity) {
1484
- var time = document.createElement("span");
1485
- time.textContent = relativeTime(s.lastActivity);
1486
- meta.appendChild(time);
1487
- }
1488
- if (s.model) {
1489
- var model = document.createElement("span");
1490
- model.className = "badge";
1491
- model.textContent = s.model;
1492
- meta.appendChild(model);
1493
- }
1494
- if (s.gitBranch) {
1495
- var branch = document.createElement("span");
1496
- branch.className = "badge";
1497
- branch.textContent = s.gitBranch;
1498
- meta.appendChild(branch);
1499
- }
1500
- item.appendChild(meta);
1501
-
1502
- (function (sessionId) {
1503
- item.addEventListener("click", function () {
1504
- if (getWs() && store.get('connected')) {
1505
- getWs().send(JSON.stringify({ type: "resume_session", cliSessionId: sessionId }));
1506
- }
1507
- var modal = document.getElementById("resume-modal");
1508
- if (modal) modal.classList.add("hidden");
1509
- closeSidebar();
1510
- });
1511
- })(s.sessionId);
1512
-
1513
- pickerList.appendChild(item);
1514
- }
1515
- }
package/lib/sessions.js CHANGED
@@ -222,8 +222,144 @@ function createSessionManager(opts) {
222
222
  }
223
223
  }
224
224
 
225
- // Load persisted sessions from disk
225
+ // Adopt orphaned CLI sessions from ~/.claude/projects/<encoded-cwd>/ as
226
+ // Clay session records. After this runs the sidebar shows a single
227
+ // unified list of sessions regardless of whether they were born inside
228
+ // Clay or via the `claude` CLI directly. The user's claudeOpenMode pref
229
+ // decides how each click renders (TUI respawn vs GUI hydration) - both
230
+ // paths already exist for born-TUI sessions.
231
+ //
232
+ // Adopted records are saved with mode='tui' because they originated in
233
+ // the CLI, not via the SDK. The cross-mode click logic in
234
+ // project-sessions.js (prepareTuiSessionForGuiView + respawn) handles
235
+ // rendering them in either mode without further special-casing.
236
+ //
237
+ // Strict skip rules:
238
+ // - cliSessionId already known to Clay (avoids duplicate records)
239
+ // - File has zero user messages (incomplete / corrupted file)
240
+ // - Warmup shape: 1 user message that is literal "hi", 0 assistant
241
+ // messages (covered by daemon cleanup but defensive here too)
242
+ function adoptOrphanedCliSessions() {
243
+ var encodedCwd = utils.encodeCwd(cwd);
244
+ var cliDir = path.join(config.REAL_HOME, ".claude", "projects", encodedCwd);
245
+ var files;
246
+ try { files = fs.readdirSync(cliDir); } catch (e) { return; }
247
+
248
+ // Build set of cliSessionIds Clay already tracks
249
+ var knownCliIds = new Set();
250
+ sessions.forEach(function (s) {
251
+ if (s.cliSessionId) knownCliIds.add(s.cliSessionId);
252
+ });
253
+
254
+ var MAX_READ = 64 * 1024; // first 64KB is enough for first user message
255
+ var adopted = 0;
256
+
257
+ for (var i = 0; i < files.length; i++) {
258
+ if (!files[i].endsWith(".jsonl")) continue;
259
+ var cliSid = files[i].slice(0, -".jsonl".length);
260
+ if (knownCliIds.has(cliSid)) continue;
261
+
262
+ var fp = path.join(cliDir, files[i]);
263
+ var raw;
264
+ try {
265
+ var stat = fs.statSync(fp);
266
+ var fd = fs.openSync(fp, "r");
267
+ var bytesToRead = Math.min(stat.size, MAX_READ);
268
+ var buf = Buffer.alloc(bytesToRead);
269
+ fs.readSync(fd, buf, 0, bytesToRead, 0);
270
+ fs.closeSync(fd);
271
+ raw = buf.toString("utf8");
272
+ } catch (e) { continue; }
273
+
274
+ var lines = raw.split("\n");
275
+ var userCount = 0;
276
+ var assistantCount = 0;
277
+ var firstUserText = null;
278
+ var createdAtIso = null;
279
+ for (var li = 0; li < lines.length; li++) {
280
+ var line = lines[li];
281
+ if (!line) continue;
282
+ var ev;
283
+ try { ev = JSON.parse(line); } catch (e) { continue; }
284
+ if (!ev || typeof ev !== "object") continue;
285
+ if (ev.type === "user" && ev.message && ev.message.role === "user") {
286
+ userCount++;
287
+ if (firstUserText == null) {
288
+ var c = ev.message.content;
289
+ if (typeof c === "string") {
290
+ firstUserText = c;
291
+ } else if (Array.isArray(c)) {
292
+ var parts = [];
293
+ for (var ci = 0; ci < c.length; ci++) {
294
+ if (c[ci] && c[ci].type === "text" && typeof c[ci].text === "string") parts.push(c[ci].text);
295
+ }
296
+ firstUserText = parts.join("");
297
+ }
298
+ if (ev.timestamp && !createdAtIso) createdAtIso = ev.timestamp;
299
+ }
300
+ } else if (ev.type === "assistant") {
301
+ assistantCount++;
302
+ }
303
+ }
304
+
305
+ if (userCount === 0) continue;
306
+ // Defensive warmup skip (daemon also cleans these)
307
+ if (userCount === 1 && assistantCount === 0 && firstUserText === "hi") continue;
308
+
309
+ // Title: first user message trimmed/truncated. Fall back to a
310
+ // neutral label so the sidebar entry is identifiable.
311
+ var title = (firstUserText || "").trim().replace(/\s+/g, " ");
312
+ if (title.length > 60) title = title.slice(0, 57) + "...";
313
+ if (!title) title = "Imported CLI session";
314
+
315
+ var createdAt = Date.now();
316
+ if (createdAtIso) {
317
+ var t = Date.parse(createdAtIso);
318
+ if (!isNaN(t)) createdAt = t;
319
+ }
320
+ var lastActivity = createdAt;
321
+ try { lastActivity = fs.statSync(fp).mtimeMs; } catch (e) {}
322
+
323
+ var localId = nextLocalId++;
324
+ var session = {
325
+ localId: localId,
326
+ queryInstance: null,
327
+ messageQueue: null,
328
+ cliSessionId: cliSid,
329
+ blocks: {},
330
+ sentToolResults: {},
331
+ pendingPermissions: {},
332
+ pendingAskUser: {},
333
+ isProcessing: false,
334
+ title: title,
335
+ createdAt: createdAt,
336
+ lastActivity: lastActivity,
337
+ history: [],
338
+ messageUUIDs: [],
339
+ lastRewindUuid: null,
340
+ vendor: "claude",
341
+ mode: "tui",
342
+ terminalId: null,
343
+ runtimeMode: null,
344
+ runtimeTerminalId: null,
345
+ sessionVisibility: "shared",
346
+ bookmarked: false,
347
+ favoriteOrder: null,
348
+ };
349
+ sessions.set(localId, session);
350
+ try { saveSessionFile(session); } catch (e) {}
351
+ knownCliIds.add(cliSid);
352
+ adopted++;
353
+ }
354
+
355
+ if (adopted > 0) {
356
+ console.log("[sessions] Adopted " + adopted + " CLI session(s) for " + cwd);
357
+ }
358
+ }
359
+
360
+ // Load persisted sessions from disk, then adopt any orphan CLI sessions
226
361
  loadSessions();
362
+ adoptOrphanedCliSessions();
227
363
 
228
364
  function getActiveSession() {
229
365
  return sessions.get(activeSessionId) || null;
@@ -234,9 +234,10 @@ function attachPreferences(deps) {
234
234
  // the cutover persist normally.
235
235
  //
236
236
  // The preference applies on the next session open. Currently displayed
237
- // sessions are not re-rendered retroactively. Existing TUI-born sessions
238
- // always stay TUI regardless of preference (use Import CLI to bring a
239
- // TUI conversation into the GUI flow).
237
+ // sessions are not re-rendered retroactively. The cross-mode click
238
+ // logic in project-sessions.js handles both directions in-place
239
+ // (prepareTuiSessionForGuiView for tui->gui rendering, claude --resume
240
+ // PTY respawn for gui->tui rendering on a born-tui session).
240
241
 
241
242
  // Single cutover instant for both default flip and one-time reset:
242
243
  // 2026-06-15 00:00 in UTC+14 = 2026-06-14 10:00 UTC.
package/lib/ws-schema.js CHANGED
@@ -23,11 +23,9 @@ var schema = {
23
23
  "set_session_bookmark": { direction: "c2s", handler: "lib/project-sessions.js", description: "Bookmark or unbookmark a session in the sidebar" },
24
24
  "reorder_session_bookmarks": { direction: "c2s", handler: "lib/project-sessions.js", description: "Reorder favorited sessions within the favorites area" },
25
25
  "bulk_delete_sessions": { direction: "c2s", handler: "lib/project-sessions.js", description: "Delete a group of sessions at once" },
26
- "resume_session": { direction: "c2s", handler: "lib/project-sessions.js", description: "Resume a CLI session by its CLI session ID" },
27
26
  "set_session_visibility": { direction: "c2s", handler: "lib/project-sessions.js", description: "Show or hide a session in the sidebar" },
28
27
  "search_sessions": { direction: "c2s", handler: "lib/project-sessions.js", description: "Search session titles" },
29
28
  "search_session_content": { direction: "c2s", handler: "lib/project-sessions.js", description: "Full-text search within a session" },
30
- "list_cli_sessions": { direction: "c2s", handler: "lib/project-sessions.js", description: "List active CLI sessions available for resume" },
31
29
  "load_more_history": { direction: "c2s", handler: "lib/project-sessions.js", description: "Request older history entries for the current session" },
32
30
  "fork_session": { direction: "c2s", handler: "lib/project-sessions.js", description: "Fork a session from a given message UUID" },
33
31
  "input_sync": { direction: "c2s", handler: "lib/project-sessions.js", description: "Sync the current input field text to other clients" },
@@ -40,7 +38,6 @@ var schema = {
40
38
  "session_unread": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Unread message count for a session" },
41
39
  "search_results": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Session title search results" },
42
40
  "search_content_results": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full-text content search results" },
43
- "cli_session_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "List of resumable CLI sessions" },
44
41
  "fork_complete": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Fork succeeded, includes new session ID" },
45
42
  "input_sync_broadcast": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Broadcast input text from another client" },
46
43
 
@@ -649,6 +649,11 @@ async function handleWarmup(msg) {
649
649
  warmupOptions.abortController = ac;
650
650
  applyWorkerClaudeBinary(warmupOptions);
651
651
 
652
+ // Capture session_id so we can delete the SDK-created CLI jsonl after
653
+ // abort. Without this every warmup leaves a one-line ("hi") session
654
+ // file in ~/.claude/projects/<encodedCwd>/ that pollutes the Resume
655
+ // CLI session list.
656
+ var warmupSessionId = null;
652
657
  try {
653
658
  var stream = sdk.query({
654
659
  prompt: mq,
@@ -657,6 +662,7 @@ async function handleWarmup(msg) {
657
662
 
658
663
  for await (var event of stream) {
659
664
  if (event.type === "system" && event.subtype === "init") {
665
+ warmupSessionId = event.session_id || null;
660
666
  var result = {
661
667
  slashCommands: event.slash_commands || [],
662
668
  model: event.model || "",
@@ -682,6 +688,19 @@ async function handleWarmup(msg) {
682
688
  sendToDaemon({ type: "warmup_error", error: "Warmup failed: " + (e.message || e) });
683
689
  }
684
690
  }
691
+
692
+ // Delete the warmup CLI jsonl. Worker runs as the target OS user so
693
+ // os.homedir() resolves to that user's home, which is where the SDK
694
+ // wrote the file. Best-effort cleanup.
695
+ if (warmupSessionId) {
696
+ try {
697
+ var os = require("os");
698
+ var wmCwd = (warmupOptions && warmupOptions.cwd) || process.cwd();
699
+ var wmEncoded = wmCwd.replace(/[^a-zA-Z0-9]/g, "-");
700
+ var wmPath = path.join(os.homedir(), ".claude", "projects", wmEncoded, warmupSessionId + ".jsonl");
701
+ fs.unlinkSync(wmPath);
702
+ } catch (e) {}
703
+ }
685
704
  }
686
705
 
687
706
  // --- Cleanup ---
@@ -1079,11 +1079,18 @@ function createClaudeAdapter(opts) {
1079
1079
  },
1080
1080
  };
1081
1081
 
1082
+ // Capture the session_id from the system/init event so we can
1083
+ // delete the SDK-created jsonl file after we abort. The SDK starts
1084
+ // a real CLI session as soon as it receives our dummy "hi" prompt;
1085
+ // without cleanup these one-line ("hi") sessions pile up in the
1086
+ // Resume CLI session list.
1087
+ var warmupSessionId = null;
1082
1088
  try {
1083
1089
  var stream = sdk.query({ prompt: mq, options: warmupOptions });
1084
1090
 
1085
1091
  for await (var msg of stream) {
1086
1092
  if (msg.type === "system" && msg.subtype === "init") {
1093
+ warmupSessionId = msg.session_id || null;
1087
1094
  result.skills = msg.skills || [];
1088
1095
  result.defaultModel = msg.model || "";
1089
1096
  result.slashCommands = msg.slash_commands || [];
@@ -1107,6 +1114,17 @@ function createClaudeAdapter(opts) {
1107
1114
  }
1108
1115
  }
1109
1116
 
1117
+ // Delete the warmup CLI jsonl file. Best-effort: file may not exist
1118
+ // (race conditions, alt SDK behavior, etc.) - silently skip.
1119
+ if (warmupSessionId) {
1120
+ try {
1121
+ var wmCwd = (initOpts && initOpts.cwd) || _cwd;
1122
+ var wmEncoded = wmCwd.replace(/[^a-zA-Z0-9]/g, "-");
1123
+ var wmPath = path.join(os.homedir(), ".claude", "projects", wmEncoded, warmupSessionId + ".jsonl");
1124
+ fs.unlinkSync(wmPath);
1125
+ } catch (e) {}
1126
+ }
1127
+
1110
1128
  return result;
1111
1129
  },
1112
1130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.39.0-beta.3",
3
+ "version": "2.39.0-beta.4",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",