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.
- package/lib/daemon.js +6 -27
- package/lib/project-connection.js +21 -8
- package/lib/project-session-pair.js +312 -0
- package/lib/project-session-spawn.js +8 -4
- package/lib/project-sessions.js +108 -10
- package/lib/project-user-message.js +18 -8
- package/lib/project.js +33 -0
- package/lib/public/app.js +20 -0
- package/lib/public/css/menus.css +83 -1
- package/lib/public/css/messages.css +14 -0
- package/lib/public/css/pane.css +420 -0
- package/lib/public/css/sidebar.css +40 -0
- package/lib/public/css/sticky-notes.css +2 -0
- package/lib/public/index.html +4 -0
- package/lib/public/modules/app-connection.js +6 -0
- package/lib/public/modules/app-favicon.js +5 -0
- package/lib/public/modules/app-header.js +101 -6
- package/lib/public/modules/app-messages.js +59 -6
- package/lib/public/modules/app-panels.js +49 -27
- package/lib/public/modules/app-rendering.js +11 -4
- package/lib/public/modules/pane-bridge.js +41 -0
- package/lib/public/modules/pane-session.js +18 -0
- package/lib/public/modules/sidebar-sessions.js +175 -1
- package/lib/public/modules/sidebar.js +2 -0
- package/lib/public/modules/split-group-helpers.js +18 -0
- package/lib/public/modules/split-pair-ui.js +324 -0
- package/lib/public/modules/split-view.js +499 -0
- package/lib/public/modules/sticky-notes.js +32 -91
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +111 -20
- package/lib/sdk-message-processor.js +2 -2
- package/lib/server.js +24 -4
- package/lib/session-hygiene.js +100 -0
- package/lib/session-pair-mcp-server.js +28 -0
- package/lib/session-split-groups.js +298 -0
- package/lib/sessions.js +163 -34
- package/lib/ws-request.js +14 -0
- package/lib/ws-schema.js +13 -0
- package/lib/yoke/adapters/claude.js +32 -0
- package/lib/yoke/adapters/codex.js +100 -77
- package/lib/yoke/adapters/kiro.js +34 -35
- package/lib/yoke/codex-app-server.js +56 -6
- package/lib/yoke/index.js +1 -0
- package/lib/yoke/skill-discovery.js +196 -0
- package/lib/yoke/vendor-registry.js +34 -0
- package/package.json +2 -2
package/lib/public/style.css
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1772
|
+
await session.queryInstance.setEffort(clamped);
|
|
1681
1773
|
}
|
|
1682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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(
|
|
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 (
|
|
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 };
|