clay-server 2.47.0-beta.6 → 3.0.0-beta.2

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 (46) hide show
  1. package/lib/daemon.js +6 -27
  2. package/lib/project-connection.js +21 -8
  3. package/lib/project-session-pair.js +312 -0
  4. package/lib/project-session-spawn.js +8 -4
  5. package/lib/project-sessions.js +108 -10
  6. package/lib/project-user-message.js +18 -8
  7. package/lib/project.js +33 -0
  8. package/lib/public/app.js +20 -0
  9. package/lib/public/css/menus.css +83 -1
  10. package/lib/public/css/messages.css +14 -0
  11. package/lib/public/css/pane.css +420 -0
  12. package/lib/public/css/sidebar.css +40 -0
  13. package/lib/public/css/sticky-notes.css +2 -0
  14. package/lib/public/index.html +4 -0
  15. package/lib/public/modules/app-connection.js +6 -0
  16. package/lib/public/modules/app-favicon.js +5 -0
  17. package/lib/public/modules/app-header.js +101 -6
  18. package/lib/public/modules/app-messages.js +59 -6
  19. package/lib/public/modules/app-panels.js +49 -27
  20. package/lib/public/modules/app-rendering.js +11 -4
  21. package/lib/public/modules/pane-bridge.js +41 -0
  22. package/lib/public/modules/pane-session.js +18 -0
  23. package/lib/public/modules/sidebar-sessions.js +175 -1
  24. package/lib/public/modules/sidebar.js +2 -0
  25. package/lib/public/modules/split-group-helpers.js +18 -0
  26. package/lib/public/modules/split-pair-ui.js +324 -0
  27. package/lib/public/modules/split-view.js +499 -0
  28. package/lib/public/modules/sticky-notes.js +32 -91
  29. package/lib/public/style.css +1 -0
  30. package/lib/sdk-bridge.js +111 -20
  31. package/lib/sdk-message-processor.js +2 -2
  32. package/lib/server.js +24 -4
  33. package/lib/session-hygiene.js +100 -0
  34. package/lib/session-pair-mcp-server.js +28 -0
  35. package/lib/session-split-groups.js +298 -0
  36. package/lib/sessions.js +163 -34
  37. package/lib/ws-request.js +14 -0
  38. package/lib/ws-schema.js +13 -0
  39. package/lib/yoke/adapters/claude.js +32 -0
  40. package/lib/yoke/adapters/codex.js +100 -77
  41. package/lib/yoke/adapters/kiro.js +34 -35
  42. package/lib/yoke/codex-app-server.js +56 -6
  43. package/lib/yoke/index.js +1 -0
  44. package/lib/yoke/skill-discovery.js +196 -0
  45. package/lib/yoke/vendor-registry.js +34 -0
  46. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  @import url("css/base.css");
2
2
  @import url("css/icon-strip.css");
3
3
  @import url("css/title-bar.css");
4
+ @import url("css/pane.css");
4
5
  @import url("css/sidebar.css");
5
6
  @import url("css/overlays.css");
6
7
  @import url("css/menus.css");
package/lib/sdk-bridge.js CHANGED
@@ -596,7 +596,8 @@ function createSDKBridge(opts) {
596
596
  function handleCanUseTool(session, toolName, input, opts) {
597
597
  // Full-auto mode: auto-approve everything except AskUserQuestion
598
598
  // (which still needs to go through the user interaction flow).
599
- if (sm.currentPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
599
+ var clayPermissionMode = session.permissionMode || sm.currentPermissionMode;
600
+ if (clayPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
600
601
  return Promise.resolve({ behavior: "allow", updatedInput: input });
601
602
  }
602
603
 
@@ -780,17 +781,21 @@ function createSDKBridge(opts) {
780
781
  sendToSession(session, { type: "context_usage", data: metaData.data });
781
782
  break;
782
783
  case "model_changed":
784
+ session.model = metaData.model;
785
+ sm.saveSessionFile(session);
783
786
  sm.currentModel = metaData.model;
784
787
  sendModelInfoForVendor(session.vendor || (adapter && adapter.vendor) || "claude", metaData.model);
785
- send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
788
+ sendToSession(session, { type: "config_state", model: session.model || sm.currentModel, mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
786
789
  break;
787
790
  case "effort_changed":
791
+ session.effort = metaData.effort;
792
+ sm.saveSessionFile(session);
788
793
  sm.currentEffort = metaData.effort;
789
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
794
+ sendToSession(session, { type: "config_state", model: session.model || sm.currentModel || "", mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort, betas: sm.currentBetas || [] });
790
795
  break;
791
796
  case "permission_mode_changed":
792
797
  sm.currentPermissionMode = metaData.mode;
793
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
798
+ sendToSession(session, { type: "config_state", model: session.model || sm.currentModel || "", mode: session.permissionMode || sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
794
799
  break;
795
800
  case "worker_error":
796
801
  send({ type: "error", text: metaData.error });
@@ -1076,7 +1081,24 @@ function createSDKBridge(opts) {
1076
1081
  return { sessionId: result.sessionId, useLocalHistory: false };
1077
1082
  }
1078
1083
 
1084
+ // Wrapper: marks the boot window so pushMessage can buffer instead of
1085
+ // dropping messages that arrive while createQuery is still awaiting.
1079
1086
  async function startQuery(session, text, images, linuxUser) {
1087
+ session._queryStarting = true;
1088
+ try {
1089
+ return await startQueryInner(session, text, images, linuxUser);
1090
+ } finally {
1091
+ session._queryStarting = false;
1092
+ if (!session.queryInstance && session.pendingPush && session.pendingPush.length) {
1093
+ // The query never came up (auth/adapter failure already reported to
1094
+ // the user). Drop the backlog rather than leaking it into a future
1095
+ // unrelated query.
1096
+ session.pendingPush = [];
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ async function startQueryInner(session, text, images, linuxUser) {
1080
1102
  async function ensureVendorReady(vendor) {
1081
1103
  if (!vendor) return null;
1082
1104
  if (linuxUser && !supportsOsUserIsolation(vendor)) return null;
@@ -1260,6 +1282,13 @@ function createSDKBridge(opts) {
1260
1282
  } else if (thinkingMode === "budget") {
1261
1283
  var budgetTokens = ls.thinkingBudget || sm.currentThinkingBudget;
1262
1284
  if (budgetTokens) claudeOpts.thinking = { type: "enabled", budgetTokens: budgetTokens };
1285
+ } else {
1286
+ // Adaptive (the default). Request summarized display explicitly:
1287
+ // current models default thinking.display to "omitted", which streams
1288
+ // thinking blocks whose text is an EMPTY string -- the UI's thinking
1289
+ // section stays blank even though thinking happens and is billed.
1290
+ // (SDK ThinkingAdaptive: { type: "adaptive", display?: "summarized" | "omitted" })
1291
+ claudeOpts.thinking = { type: "adaptive", display: "summarized" };
1263
1292
  }
1264
1293
 
1265
1294
  if (ls.permissionMode) {
@@ -1271,13 +1300,16 @@ function createSDKBridge(opts) {
1271
1300
  claudeOpts.settings = Object.assign({}, claudeOpts.settings || {}, { disableAllHooks: ls.disableAllHooks });
1272
1301
  }
1273
1302
 
1274
- if (dangerouslySkipPermissions) {
1303
+ if (dangerouslySkipPermissions || (session.mode === "tui" && session.dangerouslySkipPermissions)) {
1275
1304
  claudeOpts.allowDangerouslySkipPermissions = true;
1276
1305
  claudeOpts.permissionMode = "bypassPermissions";
1277
1306
  } else {
1278
- var globalMode = sm.currentPermissionMode || "default";
1307
+ var globalMode = session.permissionMode || sm.currentPermissionMode || "default";
1279
1308
  var effectiveDefault;
1280
- if (globalMode === "bypassPermissions") effectiveDefault = "bypassPermissions";
1309
+ // GUI full access stays inside Clay's canUseTool callback. Passing the
1310
+ // SDK bypass mode would require its process-level dangerous escape hatch
1311
+ // and would skip Clay's permission policy entirely.
1312
+ if (globalMode === "bypassPermissions") effectiveDefault = "default";
1281
1313
  else if (session.acceptEditsAfterStart) effectiveDefault = "acceptEdits";
1282
1314
  else effectiveDefault = globalMode;
1283
1315
  var modeToApply = session._loopPermissionMode || effectiveDefault;
@@ -1315,7 +1347,7 @@ function createSDKBridge(opts) {
1315
1347
  // Claude would reject the unknown model. We validate against the
1316
1348
  // session vendor's model list regardless of which vendor happens to be
1317
1349
  // the project's default adapter.
1318
- var queryModel = (ls.model && ls.model !== "default" ? ls.model : null) || sm.currentModel || undefined;
1350
+ var queryModel = (ls.model && ls.model !== "default" ? ls.model : null) || session.model || sm.currentModel || undefined;
1319
1351
  var sessionVendor = session.vendor || (adapter && adapter.vendor) || null;
1320
1352
  if (sessionVendor) {
1321
1353
  var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[sessionVendor]) || [];
@@ -1330,6 +1362,14 @@ function createSDKBridge(opts) {
1330
1362
  queryModel = modelEntryValue(queryModel) || undefined;
1331
1363
  }
1332
1364
 
1365
+ // Bind the resolved model to the session so it survives a daemon
1366
+ // restart. Without this, a session started under sm.currentModel (an
1367
+ // in-memory value) came back after restart on the adapter default.
1368
+ if (queryModel && session.model !== queryModel) {
1369
+ session.model = queryModel;
1370
+ sm.saveSessionFile(session);
1371
+ }
1372
+
1333
1373
  var codexConfig = getCodexConfig(sm);
1334
1374
  var kiroConfig = getKiroConfig(sm);
1335
1375
  var mergedMcpServers = mergeMcpServers(getMcpServers(session), getRemoteMcpServers) || undefined;
@@ -1356,8 +1396,23 @@ function createSDKBridge(opts) {
1356
1396
 
1357
1397
  var queryOpts = {
1358
1398
  cwd: cwd,
1399
+ // appendSystemPrompt, NOT systemPrompt: a plain-string systemPrompt
1400
+ // REPLACES the entire Claude Code system prompt (sdk.d.ts:2096), which
1401
+ // would strip the driver session down to the two-sentence pair preset.
1402
+ // Adapters map this to their additive mechanism (claude: preset+append,
1403
+ // codex/kiro: prepend to the first message).
1404
+ appendSystemPrompt: typeof opts.getSessionSystemPrompt === "function" ? (opts.getSessionSystemPrompt(session) || undefined) : undefined,
1359
1405
  model: queryModel,
1360
- effort: ls.effort || sm.currentEffort || undefined,
1406
+ // Effort is remembered per vendor and clamped to the session vendor's
1407
+ // supported levels: a global value would leak codex "minimal" into
1408
+ // Claude queries (and Claude "max" into codex) after vendor switches.
1409
+ effort: yoke.clampEffort(
1410
+ session.vendor || sm.defaultVendor || "claude",
1411
+ ls.effort
1412
+ || session.effort
1413
+ || (sm.currentEffortByVendor && sm.currentEffortByVendor[session.vendor || sm.defaultVendor || "claude"])
1414
+ || sm.currentEffort
1415
+ ) || undefined,
1361
1416
  title: initialTitle || undefined,
1362
1417
  toolServers: mergedMcpServers,
1363
1418
  toolServerDescriptors: extractMcpDescriptors(mergedMcpServers) || undefined,
@@ -1455,6 +1510,18 @@ function createSDKBridge(opts) {
1455
1510
  handle.pushMessage(text, images);
1456
1511
  console.log("[sdk-bridge] pushMessage done, starting processQueryStream...");
1457
1512
 
1513
+ // Flush messages that arrived while createQuery was still awaiting
1514
+ // (pushMessage buffers them during the boot window). Synchronous with
1515
+ // the handle assignment above, so ordering vs new pushes is safe.
1516
+ if (session.pendingPush && session.pendingPush.length) {
1517
+ var backlog = session.pendingPush;
1518
+ session.pendingPush = [];
1519
+ console.log("[sdk-bridge] flushing " + backlog.length + " buffered message(s) into new query");
1520
+ for (var bi = 0; bi < backlog.length; bi++) {
1521
+ handle.pushMessage(backlog[bi].text, backlog[bi].images);
1522
+ }
1523
+ }
1524
+
1458
1525
  // For single-turn sessions (Ralph Loop), end the message queue so the SDK
1459
1526
  // query finishes after processing the one message. Without this, the query
1460
1527
  // stream stays open forever waiting for more messages, and onQueryComplete
@@ -1468,12 +1535,25 @@ function createSDKBridge(opts) {
1468
1535
  });
1469
1536
  }
1470
1537
 
1538
+ // Returns true when the message has a consumer (delivered to a live
1539
+ // handle, or buffered for the query currently booting). Returns false
1540
+ // when there is nothing to deliver to -- callers must fall back to
1541
+ // startQuery instead of assuming delivery. The old void version silently
1542
+ // dropped messages in both gap windows, which is what made a session show
1543
+ // the processing indicator without ever answering until a second send.
1471
1544
  function pushMessage(session, text, images) {
1472
1545
  session.lastActivityAt = Date.now();
1473
1546
  // Route through QueryHandle (works for both in-process and worker paths)
1474
1547
  if (session.queryInstance && typeof session.queryInstance.pushMessage === "function") {
1475
1548
  session.queryInstance.pushMessage(text, images);
1549
+ return true;
1550
+ }
1551
+ if (session._queryStarting) {
1552
+ session.pendingPush = session.pendingPush || [];
1553
+ session.pendingPush.push({ text: text, images: images || null });
1554
+ return true;
1476
1555
  }
1556
+ return false;
1477
1557
  }
1478
1558
 
1479
1559
  function permissionPushTitle(toolName, input) {
@@ -1652,51 +1732,62 @@ function createSDKBridge(opts) {
1652
1732
  }
1653
1733
  if (!session.queryInstance) {
1654
1734
  // No active query — just store the model for next startQuery
1735
+ session.model = model;
1736
+ sm.saveSessionFile(session);
1655
1737
  sm.currentModel = model;
1656
1738
  // Don't send vendor here: session vendor not yet bound, let client keep its selection
1657
1739
  sendModelInfoForVendor(null, model);
1658
- send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1740
+ sendToSession(session, { type: "config_state", model: sm.currentModel, mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1659
1741
  return;
1660
1742
  }
1661
1743
  try {
1662
1744
  await session.queryInstance.setModel(model);
1745
+ session.model = model;
1746
+ sm.saveSessionFile(session);
1663
1747
  sm.currentModel = model;
1664
1748
  var sessionVendor = session.vendor || (adapter && adapter.vendor) || "claude";
1665
1749
  sendModelInfoForVendor(sessionVendor, model);
1666
- send({ type: "config_state", model: sm.currentModel, mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1750
+ sendToSession(session, { type: "config_state", model: sm.currentModel, mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1667
1751
  } catch (e) {
1668
1752
  send({ type: "error", text: "Failed to switch model: " + (e.message || e) });
1669
1753
  }
1670
1754
  }
1671
1755
 
1672
1756
  async function setEffort(session, effort) {
1757
+ // Remember the pick per vendor (clamped) so vendor switches don't leak
1758
+ // unsupported levels across sessions; sm.currentEffort stays as the
1759
+ // display value for the session the user just configured.
1760
+ var effortVendor = session.vendor || sm.defaultVendor || "claude";
1761
+ var clamped = yoke.clampEffort(effortVendor, effort) || effort;
1762
+ sm.currentEffortByVendor = sm.currentEffortByVendor || {};
1763
+ sm.currentEffortByVendor[effortVendor] = clamped;
1764
+ session.effort = clamped;
1765
+ sm.saveSessionFile(session);
1766
+ sm.currentEffort = clamped;
1673
1767
  if (!session.queryInstance) {
1674
- sm.currentEffort = effort;
1675
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
1676
- return;
1768
+ return clamped;
1677
1769
  }
1678
1770
  // Route through QueryHandle (works for both in-process and worker paths)
1679
1771
  if (typeof session.queryInstance.setEffort === "function") {
1680
- await session.queryInstance.setEffort(effort);
1772
+ await session.queryInstance.setEffort(clamped);
1681
1773
  }
1682
- sm.currentEffort = effort;
1683
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] });
1774
+ return clamped;
1684
1775
  }
1685
1776
 
1686
1777
  async function setPermissionMode(session, mode) {
1687
1778
  if (!session.queryInstance) {
1688
1779
  // No active query — just store the mode for next startQuery
1689
1780
  sm.currentPermissionMode = mode;
1690
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1781
+ sendToSession(session, { type: "config_state", model: session.model || sm.currentModel || "", mode: sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1691
1782
  return;
1692
1783
  }
1693
1784
  try {
1694
1785
  // Route through QueryHandle (works for both in-process and worker paths)
1695
1786
  await session.queryInstance.setPermissionMode(mode);
1696
1787
  sm.currentPermissionMode = mode;
1697
- send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode, effort: sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1788
+ sendToSession(session, { type: "config_state", model: session.model || sm.currentModel || "", mode: sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1698
1789
  } catch (e) {
1699
- send({ type: "error", text: "Failed to set permission mode: " + (e.message || e) });
1790
+ sendToSession(session, { type: "error", text: "Failed to set permission mode: " + (e.message || e) });
1700
1791
  }
1701
1792
  }
1702
1793
 
@@ -498,7 +498,7 @@ function attachMessageProcessor(ctx) {
498
498
  if (_donePreviewText.length > 140) _donePreviewText = _donePreviewText.substring(0, 140) + "...";
499
499
  var _doneTitle = mateDisplayName ? (mateDisplayName + " responded") : (session.title || "Claude");
500
500
 
501
- if (pushModule) {
501
+ if (pushModule && !session._delegatedBy) {
502
502
  pushModule.sendPush({
503
503
  type: "done",
504
504
  slug: slug,
@@ -509,7 +509,7 @@ function attachMessageProcessor(ctx) {
509
509
  }
510
510
 
511
511
  var _nm = getNotificationsModule();
512
- if (_nm && !session.loop) {
512
+ if (_nm && !session.loop && !session._delegatedBy) {
513
513
  _nm.notify("response_done", {
514
514
  title: _doneTitle,
515
515
  preview: _donePreviewText,
package/lib/server.js CHANGED
@@ -19,6 +19,7 @@ var serverSettings = require("./server-settings");
19
19
  var serverPalette = require("./server-palette");
20
20
  var serverEmail = require("./server-email");
21
21
  var serverGlobalWs = require("./server-global-ws");
22
+ var { parseWsRequestUrl } = require("./ws-request");
22
23
 
23
24
  var { CONFIG_DIR } = require("./config");
24
25
  var { provisionLinuxUser } = require("./os-users");
@@ -350,8 +351,11 @@ function createServer(opts) {
350
351
  // --- Security headers ---
351
352
  var securityHeaders = {
352
353
  "X-Content-Type-Options": "nosniff",
353
- "X-Frame-Options": "DENY",
354
- "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src * data: blob:; connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://esm.sh https://api.dicebear.com https://api.open-meteo.com https://ipapi.co; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net;",
354
+ // SAMEORIGIN (not DENY): split-view panes frame the app from the same
355
+ // origin. External sites still cannot frame Clay; frame-ancestors below
356
+ // is the modern equivalent for browsers that honor CSP.
357
+ "X-Frame-Options": "SAMEORIGIN",
358
+ "Content-Security-Policy": "default-src 'self'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://esm.sh; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src * data: blob:; connect-src 'self' ws: wss: https://cdn.jsdelivr.net https://esm.sh https://api.dicebear.com https://api.open-meteo.com https://ipapi.co; font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net;",
355
359
  };
356
360
  if (tlsOptions) {
357
361
  securityHeaders["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
@@ -439,6 +443,11 @@ function createServer(opts) {
439
443
  pctx.sm.sessions.forEach(function (s) {
440
444
  if (matchedCtx) return;
441
445
  if (s.cliSessionId !== cliSid) return;
446
+ // Adopted external CLI sessions never had a Clay PTY: the user is
447
+ // sitting in that terminal already, so hook-driven attention
448
+ // banners are noise. They start mattering once Clay attaches a
449
+ // PTY (resume in Clay sets runtimeTerminalId).
450
+ if (s.adopted && typeof s.terminalId !== "number" && typeof s.runtimeTerminalId !== "number") return;
442
451
  if (s.mode === "tui" || s.runtimeMode === "tui") {
443
452
  matchedCtx = pctx;
444
453
  matchedLocalId = s.localId;
@@ -894,6 +903,8 @@ function createServer(opts) {
894
903
  });
895
904
 
896
905
  server.on("upgrade", function (req, socket, head) {
906
+ var wsRequest = parseWsRequestUrl(req.url);
907
+ var wsPathname = wsRequest.path;
897
908
  // Origin validation (CSRF prevention)
898
909
  var origin = req.headers.origin;
899
910
  if (origin) {
@@ -930,23 +941,30 @@ function createServer(opts) {
930
941
  }
931
942
 
932
943
  // Extract slug from WS URL: /p/{slug}/ws
933
- var wsSlug = extractSlug(req.url);
944
+ var wsSlug = extractSlug(wsPathname);
934
945
  if (!wsSlug) {
935
946
  // Slug-less /ws: bootstrap channel for a client that hasn't entered
936
947
  // any project yet (no projects exist, or none accessible to this user
937
948
  // but they can still create one). Anything other than exactly /ws is
938
949
  // rejected.
939
- if (req.url !== "/ws") {
950
+ if (wsPathname !== "/ws") {
940
951
  socket.destroy();
941
952
  return;
942
953
  }
943
954
  var globalUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
944
955
  wss.handleUpgrade(req, socket, head, function (ws) {
956
+ ws._clayPane = wsRequest.pane;
957
+ ws._clayPaneSession = wsRequest.paneSession;
945
958
  globalWs.handleConnection(ws, globalUser);
946
959
  });
947
960
  return;
948
961
  }
949
962
 
963
+ if (stripPrefix(wsPathname, wsSlug) !== "/ws") {
964
+ socket.destroy();
965
+ return;
966
+ }
967
+
950
968
  var ctx = projects.get(wsSlug);
951
969
  if (!ctx) {
952
970
  if (debug) console.log("[server] WS rejected: project not found for slug", wsSlug);
@@ -1000,6 +1018,8 @@ function createServer(opts) {
1000
1018
  return origEmit.apply(ws, arguments);
1001
1019
  };
1002
1020
  ws._clayUser = wsUser; // attach user context
1021
+ ws._clayPane = wsRequest.pane;
1022
+ ws._clayPaneSession = wsRequest.paneSession;
1003
1023
  var remoteAddr = req.socket.remoteAddress || "";
1004
1024
  ws._clayLocal = (remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1");
1005
1025
  // Clear cross-project unread for this project when client connects
@@ -0,0 +1,100 @@
1
+ // Blank-session hygiene: decides which sessions are safe to reuse for a
2
+ // "New session" request and which abandoned blanks are safe to sweep.
3
+ // Pure decision logic only -- deletion/switching stays in sessions.js.
4
+
5
+ var BLANK_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
6
+ var CLAUDE_INTERRUPTED_TEXT = "[Request interrupted by user]";
7
+
8
+ // A session is "blank" when nothing has ever happened in it and nothing is
9
+ // attached to it. Adopted external sessions are never blank (their transcript
10
+ // lives outside Clay), TUI sessions reap themselves via their PTY onExit, and
11
+ // spawn/loop children are managed by their own lifecycles.
12
+ function isBlankSession(s) {
13
+ return !!s
14
+ && (s.turnCount || 0) === 0
15
+ && (!s.history || s.history.length === 0)
16
+ && !s.isProcessing
17
+ && !s.queryInstance
18
+ && !s.adopted
19
+ && !s.bookmarked
20
+ && !s.spawn
21
+ && !s.loop
22
+ && (s.mode || "gui") !== "tui"
23
+ && s.terminalId == null;
24
+ }
25
+
26
+ // Newest blank owned by the same user. An exact vendor match wins; a
27
+ // vendor-less blank (default adapter, nothing decided yet) is an acceptable
28
+ // fallback -- the caller stamps the requested vendor onto it before use.
29
+ function findReusableBlankSession(sessions, opts) {
30
+ var vendor = (opts && opts.vendor) || null;
31
+ var ownerId = (opts && opts.ownerId) || null;
32
+ var exact = null;
33
+ var vendorless = null;
34
+ sessions.forEach(function (s) {
35
+ if (!isBlankSession(s)) return;
36
+ if ((s.ownerId || null) !== ownerId) return;
37
+ var v = s.vendor || null;
38
+ if (v === vendor) {
39
+ if (!exact || (s.createdAt || 0) > (exact.createdAt || 0)) exact = s;
40
+ } else if (v === null) {
41
+ if (!vendorless || (s.createdAt || 0) > (vendorless.createdAt || 0)) vendorless = s;
42
+ }
43
+ });
44
+ return exact || vendorless;
45
+ }
46
+
47
+ // Blanks untouched for more than the grace period, excluding whatever a
48
+ // client is currently looking at.
49
+ function collectStaleBlankSessions(sessions, activeSessionId, now) {
50
+ var cutoff = now - BLANK_SESSION_MAX_AGE_MS;
51
+ var stale = [];
52
+ sessions.forEach(function (s) {
53
+ if (!isBlankSession(s)) return;
54
+ if (s.localId === activeSessionId) return;
55
+ if (Math.max(s.createdAt || 0, s.lastActivity || 0) >= cutoff) return;
56
+ stale.push(s.localId);
57
+ });
58
+ return stale;
59
+ }
60
+
61
+ function extractClaudeUserText(event) {
62
+ var content = event && event.message && event.message.content;
63
+ if (typeof content === "string") return content;
64
+ if (!Array.isArray(content)) return "";
65
+ var parts = [];
66
+ for (var i = 0; i < content.length; i++) {
67
+ if (content[i] && content[i].type === "text" && typeof content[i].text === "string") {
68
+ parts.push(content[i].text);
69
+ }
70
+ }
71
+ return parts.join("");
72
+ }
73
+
74
+ // SDK capability probes create a real Claude transcript with a single "hi"
75
+ // prompt. Newer Claude Code versions append an interruption record when Clay
76
+ // aborts the probe, so that bookkeeping record must not make the transcript
77
+ // look like a real two-turn conversation.
78
+ function isClaudeWarmupTranscript(events) {
79
+ var promptCount = 0;
80
+ for (var i = 0; i < events.length; i++) {
81
+ var event = events[i];
82
+ if (!event || typeof event !== "object") continue;
83
+ if (event.type === "assistant") return false;
84
+ if (event.type !== "user" || !event.message || event.message.role !== "user") continue;
85
+ var text = extractClaudeUserText(event);
86
+ if (text === CLAUDE_INTERRUPTED_TEXT) continue;
87
+ if (text !== "hi") return false;
88
+ promptCount++;
89
+ }
90
+ return promptCount === 1;
91
+ }
92
+
93
+ module.exports = {
94
+ isBlankSession: isBlankSession,
95
+ findReusableBlankSession: findReusableBlankSession,
96
+ collectStaleBlankSessions: collectStaleBlankSessions,
97
+ extractClaudeUserText: extractClaudeUserText,
98
+ isClaudeWarmupTranscript: isClaudeWarmupTranscript,
99
+ BLANK_SESSION_MAX_AGE_MS: BLANK_SESSION_MAX_AGE_MS,
100
+ };
@@ -0,0 +1,28 @@
1
+ // Partner-control tools for sessions that belong to a split group.
2
+
3
+ var buildShape = require("./session-spawn-mcp-server").buildShape;
4
+
5
+ function getToolDefs(handlers) {
6
+ return [
7
+ {
8
+ name: "send_to_partner",
9
+ description: "Delegate one concrete task to the other session in this split. The partner works visibly in its own pane. A delegated turn cannot delegate back, so keep orchestration one hop deep.",
10
+ inputSchema: buildShape({
11
+ message: { type: "string", description: "The complete task or question for the partner." },
12
+ wait: { type: "boolean", description: "Wait for the partner's turn to finish. Defaults to true." },
13
+ timeoutSeconds: { type: "number", description: "Maximum wait in seconds, from 1 to 900. Defaults to 300." },
14
+ }, ["message"]),
15
+ handler: function (args) { return handlers.send(args || {}); },
16
+ },
17
+ {
18
+ name: "read_partner",
19
+ description: "Read the partner's current status and recent conversation turns. Use this after a non-waiting delegation or timeout.",
20
+ inputSchema: buildShape({
21
+ lastTurns: { type: "number", description: "Number of recent user-message-delimited turns, from 1 to 5. Defaults to 1." },
22
+ }),
23
+ handler: function (args) { return handlers.read(args || {}); },
24
+ },
25
+ ];
26
+ }
27
+
28
+ module.exports = { getToolDefs: getToolDefs };