clay-server 4.0.1-beta.1 → 4.1.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-project-access.js +118 -0
  2. package/lib/daemon-projects.js +19 -5
  3. package/lib/daemon.js +19 -18
  4. package/lib/project-logs-access-scope.js +94 -0
  5. package/lib/project-logs-query.js +3 -0
  6. package/lib/project-logs-service.js +86 -84
  7. package/lib/project-logs-store.js +5 -3
  8. package/lib/project-pair-lifecycle.js +140 -102
  9. package/lib/project-pair-message.js +50 -0
  10. package/lib/project-pair-replacement-state.js +72 -0
  11. package/lib/project-pair-task-control.js +264 -0
  12. package/lib/project-pair-usage.js +76 -0
  13. package/lib/project-session-pair.js +115 -116
  14. package/lib/project-sessions.js +25 -1
  15. package/lib/project-worker-proposal.js +69 -58
  16. package/lib/project.js +21 -4
  17. package/lib/public/css/icon-strip.css +40 -0
  18. package/lib/public/modules/app-messages.js +9 -0
  19. package/lib/public/modules/project-access.js +141 -0
  20. package/lib/public/modules/project-grouping.js +22 -0
  21. package/lib/public/modules/sidebar-projects.js +14 -171
  22. package/lib/public/modules/worker-proposal.js +4 -0
  23. package/lib/sdk-bridge.js +20 -0
  24. package/lib/server-admin.js +3 -3
  25. package/lib/server-global-ws.js +10 -0
  26. package/lib/server-mates.js +4 -3
  27. package/lib/server-palette.js +17 -14
  28. package/lib/server.js +184 -102
  29. package/lib/session-pair-factory.js +3 -0
  30. package/lib/session-pair-history.js +40 -0
  31. package/lib/session-pair-mcp-server.js +19 -2
  32. package/lib/session-pair-operation.js +11 -0
  33. package/lib/session-pair-prompts.js +24 -2
  34. package/lib/session-pair-turn-control.js +8 -3
  35. package/lib/session-provenance.js +1 -0
  36. package/lib/session-spawn-mcp-server.js +2 -1
  37. package/lib/sessions.js +2 -0
  38. package/lib/worker-proposal-control.js +69 -0
  39. package/lib/worker-proposal-decision.js +7 -0
  40. package/lib/worker-runtime-catalog.js +52 -0
  41. package/lib/workspace-query-access.js +3 -2
  42. package/lib/workspace-query-service.js +1 -1
  43. package/lib/worktree.js +8 -4
  44. package/lib/ws-schema.js +1 -0
  45. package/lib/yoke/adapters/codex.js +25 -7
  46. package/package.json +1 -1
package/lib/server.js CHANGED
@@ -202,6 +202,23 @@ function createServer(opts) {
202
202
  var isRequestAuthed = auth.isRequestAuthed;
203
203
  var parseCookies = auth.parseCookies;
204
204
 
205
+ function canUserAccessSlug(userId, slug) {
206
+ if (!userId || !slug) return false;
207
+ var project = projects && projects.get(slug);
208
+ var status = project && project.getStatus ? project.getStatus() : null;
209
+ if (status && status.isMate === true) {
210
+ return users.canAccessProject(userId, {
211
+ visibility: "private",
212
+ ownerId: status.projectOwnerId || null,
213
+ allowedUsers: [],
214
+ }) === true;
215
+ }
216
+ if (!onGetProjectAccess) return false;
217
+ var access = onGetProjectAccess(slug);
218
+ if (!access || access.error) return false;
219
+ return users.canAccessProject(userId, access) === true;
220
+ }
221
+
205
222
  var realVersion = require("../package.json").version;
206
223
  var currentVersion = debug ? "0.0.9" : realVersion;
207
224
 
@@ -339,6 +356,13 @@ function createServer(opts) {
339
356
  if (!access || access.error) return false;
340
357
  return users.canAccessProject(userId, access) === true;
341
358
  },
359
+ hasFullProjectAccess: function (userId, status) {
360
+ if (!status || !status.slug || !onGetProjectAccess) return false;
361
+ var access = onGetProjectAccess(status.slug);
362
+ if (!access || access.error) return false;
363
+ var governing = access.isWorktree && access.parentAccess ? access.parentAccess : access;
364
+ return users.canAccessProject(userId, governing) === true;
365
+ },
342
366
  resolveMate: function (userId, mateId) {
343
367
  return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
344
368
  },
@@ -403,6 +427,7 @@ function createServer(opts) {
403
427
  projects: projects,
404
428
  getMultiUserFromReq: getMultiUserFromReq,
405
429
  onGetProjectAccess: onGetProjectAccess,
430
+ canUserAccessSlug: canUserAccessSlug,
406
431
  });
407
432
 
408
433
  // --- Push module (global) ---
@@ -662,7 +687,11 @@ function createServer(opts) {
662
687
  if (isRequestAuthed(req)) {
663
688
  var mem = process.memoryUsage();
664
689
  var activeSessions = 0;
665
- projects.forEach(function (ctx) {
690
+ var healthProjects = 0;
691
+ var healthUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
692
+ projects.forEach(function (ctx, slug) {
693
+ if (users.isMultiUser() && (!healthUser || !canUserAccessSlug(healthUser.id, slug))) return;
694
+ healthProjects++;
666
695
  if (ctx && ctx.clients) {
667
696
  activeSessions += ctx.clients.size || 0;
668
697
  }
@@ -671,7 +700,7 @@ function createServer(opts) {
671
700
  health.version = pkg.version;
672
701
  health.node = process.version;
673
702
  health.sessions = activeSessions;
674
- health.projects = projects.size;
703
+ health.projects = healthProjects;
675
704
  health.memory = {
676
705
  rss: mem.rss,
677
706
  heapUsed: mem.heapUsed,
@@ -850,7 +879,9 @@ function createServer(opts) {
850
879
  return;
851
880
  }
852
881
  var projectList = [];
882
+ var infoUser = users.isMultiUser() ? getMultiUserFromReq(req) : null;
853
883
  projects.forEach(function (ctx, slug) {
884
+ if (users.isMultiUser() && (!infoUser || !canUserAccessSlug(infoUser.id, slug))) return;
854
885
  projectList.push({ slug: slug, project: ctx.project });
855
886
  });
856
887
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -912,15 +943,12 @@ function createServer(opts) {
912
943
  res.setHeader("Set-Cookie", "clay_last_project=" + slug + "; Path=/; SameSite=Strict; Max-Age=31536000" + (tlsOptions ? "; Secure" : ""));
913
944
 
914
945
  // Multi-user: check project access for HTTP requests
915
- if (users.isMultiUser() && onGetProjectAccess) {
946
+ if (users.isMultiUser()) {
916
947
  var httpUser = getMultiUserFromReq(req);
917
- if (httpUser) {
918
- var httpAccess = onGetProjectAccess(slug);
919
- if (httpAccess && !httpAccess.error && !users.canAccessProject(httpUser.id, httpAccess)) {
920
- res.writeHead(302, { "Location": "/" });
921
- res.end();
922
- return;
923
- }
948
+ if (!httpUser || !canUserAccessSlug(httpUser.id, slug)) {
949
+ res.writeHead(302, { "Location": "/" });
950
+ res.end();
951
+ return;
924
952
  }
925
953
  }
926
954
 
@@ -1029,6 +1057,9 @@ function createServer(opts) {
1029
1057
  onAppMessage: function (ws, msg) {
1030
1058
  return handleDmMessage(ws, msg);
1031
1059
  },
1060
+ onConnection: function (ws) {
1061
+ sendProjectsUpdatedToClient(ws);
1062
+ },
1032
1063
  });
1033
1064
 
1034
1065
  server.on("upgrade", function (req, socket, head) {
@@ -1106,19 +1137,19 @@ function createServer(opts) {
1106
1137
  if (users.isMultiUser()) {
1107
1138
  wsUser = getMultiUserFromReq(req);
1108
1139
  // Check project access for multi-user mode
1109
- if (wsUser && onGetProjectAccess) {
1110
- // For worktree projects, inherit access from parent
1111
- var accessSlug = (wsSlug.indexOf("--") !== -1) ? wsSlug.split("--")[0] : wsSlug;
1112
- var projectAccess = onGetProjectAccess(accessSlug);
1140
+ if (wsUser) {
1141
+ var projectAccess = onGetProjectAccess ? onGetProjectAccess(wsSlug) : null;
1113
1142
  if (debug) console.log("[server] WS access check:", wsSlug, "user:", wsUser.id, "role:", wsUser.role, "visibility:", projectAccess && projectAccess.visibility, "ownerId:", projectAccess && projectAccess.ownerId, "allowed:", projectAccess && projectAccess.allowedUsers);
1114
- if (projectAccess && !projectAccess.error) {
1115
- if (!users.canAccessProject(wsUser.id, projectAccess)) {
1116
- if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug);
1117
- socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1118
- socket.destroy();
1119
- return;
1120
- }
1143
+ if (!canUserAccessSlug(wsUser.id, wsSlug)) {
1144
+ if (debug) console.log("[server] WS rejected: access denied for", wsUser.id, "on", wsSlug);
1145
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1146
+ socket.destroy();
1147
+ return;
1121
1148
  }
1149
+ } else {
1150
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
1151
+ socket.destroy();
1152
+ return;
1122
1153
  }
1123
1154
  }
1124
1155
 
@@ -1200,13 +1231,9 @@ function createServer(opts) {
1200
1231
  var filtered = allProjectsList;
1201
1232
  if (users.isMultiUser() && onGetProjectAccess) {
1202
1233
  var wsUser = ws._clayUser;
1203
- if (wsUser) {
1204
- filtered = allProjectsList.filter(function (p) {
1205
- var access = onGetProjectAccess(p.slug);
1206
- if (!access || access.error) return true;
1207
- return users.canAccessProject(wsUser.id, access);
1208
- });
1209
- }
1234
+ filtered = wsUser ? allProjectsList.filter(function (p) {
1235
+ return canUserAccessSlug(wsUser.id, p.slug);
1236
+ }) : [];
1210
1237
  }
1211
1238
  // Attach per-project unread counts for this client
1212
1239
  var unreadMap = getCrossProjectUnread(ws);
@@ -1306,20 +1333,12 @@ function createServer(opts) {
1306
1333
  authToken: pinHash || null,
1307
1334
  getProjectCount: function () { return projects.size; },
1308
1335
  getProjectList: function (userId) {
1309
- var list = [];
1310
- projects.forEach(function (ctx, s) {
1311
- var status = ctx.getStatus();
1312
- if (userId && users.isMultiUser() && onGetProjectAccess) {
1313
- var access = onGetProjectAccess(s);
1314
- if (access && !access.error && !users.canAccessProject(userId, access)) return;
1315
- }
1316
- list.push(status);
1317
- });
1318
- return list;
1336
+ return getVisibleProjects(userId);
1319
1337
  },
1320
- getHubSchedules: function () {
1338
+ getHubSchedules: function (userId) {
1321
1339
  var allSchedules = [];
1322
1340
  projects.forEach(function (ctx, s) {
1341
+ if (users.isMultiUser() && (!userId || !canUserAccessSlug(userId, s))) return;
1323
1342
  var status = ctx.getStatus();
1324
1343
  var recs = ctx.getSchedules();
1325
1344
  for (var i = 0; i < recs.length; i++) {
@@ -1379,6 +1398,7 @@ function createServer(opts) {
1379
1398
  if (!ctx) return 0;
1380
1399
  return ctx.getSchedules().length;
1381
1400
  },
1401
+ canAccessProjectSlug: canUserAccessSlug,
1382
1402
  onPresenceChange: broadcastPresenceChange,
1383
1403
  onProcessingChanged: broadcastProcessingChange,
1384
1404
  onSessionDone: function () { onSessionDone(slug); },
@@ -1592,27 +1612,81 @@ function createServer(opts) {
1592
1612
  function getServerUsers() {
1593
1613
  var seen = {};
1594
1614
  var list = [];
1595
- projects.forEach(function (ctx) {
1596
- ctx.forEachClient(function (ws) {
1597
- if (!ws._clayUser) return;
1598
- var u = ws._clayUser;
1599
- if (seen[u.id]) return;
1600
- seen[u.id] = true;
1601
- var p = u.profile || {};
1602
- list.push({
1603
- id: u.id,
1604
- displayName: p.name || u.displayName || u.username,
1605
- username: u.username,
1606
- avatarStyle: p.avatarStyle || "imprint",
1607
- avatarSeed: p.avatarSeed || u.username,
1608
- avatarColor: p.avatarColor || "#5857fc",
1609
- avatarCustom: p.avatarCustom || "",
1610
- });
1615
+ forEachAppClient(function (ws) {
1616
+ if (!ws._clayUser) return;
1617
+ var u = ws._clayUser;
1618
+ if (seen[u.id]) return;
1619
+ seen[u.id] = true;
1620
+ var p = u.profile || {};
1621
+ list.push({
1622
+ id: u.id,
1623
+ displayName: p.name || u.displayName || u.username,
1624
+ username: u.username,
1625
+ avatarStyle: p.avatarStyle || "imprint",
1626
+ avatarSeed: p.avatarSeed || u.username,
1627
+ avatarColor: p.avatarColor || "#5857fc",
1628
+ avatarCustom: p.avatarCustom || "",
1611
1629
  });
1612
1630
  });
1613
1631
  return list;
1614
1632
  }
1615
1633
 
1634
+ function forEachAppClient(visitor) {
1635
+ if (globalWs && typeof globalWs.forEachClient === "function") {
1636
+ globalWs.forEachClient(visitor);
1637
+ }
1638
+ projects.forEach(function (ctx) {
1639
+ ctx.forEachClient(visitor);
1640
+ });
1641
+ }
1642
+
1643
+ function getVisibleProjects(userId) {
1644
+ var visible = [];
1645
+ projects.forEach(function (ctx, slug) {
1646
+ if (users.isMultiUser() && (!userId || !canUserAccessSlug(userId, slug))) return;
1647
+ visible.push(ctx.getStatus());
1648
+ });
1649
+ return visible;
1650
+ }
1651
+
1652
+ function sendProjectsUpdatedToClient(ws, serverUsers, allUsers) {
1653
+ if (!ws || ws.readyState !== 1) return;
1654
+ var userId = ws._clayUser ? ws._clayUser.id : null;
1655
+ var visibleProjects = getVisibleProjects(userId);
1656
+ var message = {
1657
+ type: "projects_updated",
1658
+ projects: visibleProjects,
1659
+ projectCount: visibleProjects.length,
1660
+ removedProjects: getRemovedProjects(userId),
1661
+ };
1662
+ if (users.isMultiUser()) {
1663
+ message.serverUsers = serverUsers || getServerUsers();
1664
+ message.allUsers = allUsers || users.getAllUsers().map(function (user) {
1665
+ var profile = user.profile || {};
1666
+ return {
1667
+ id: user.id,
1668
+ displayName: profile.name || user.displayName || user.username,
1669
+ username: user.username,
1670
+ role: user.role,
1671
+ avatarStyle: profile.avatarStyle || "imprint",
1672
+ avatarSeed: profile.avatarSeed || user.username,
1673
+ avatarColor: profile.avatarColor || "#5857fc",
1674
+ avatarCustom: profile.avatarCustom || "",
1675
+ };
1676
+ });
1677
+ message.dmFavorites = userId ? users.getDmFavorites(userId) : [];
1678
+ message.dmConversations = [];
1679
+ if (userId) {
1680
+ var hidden = users.getDmHidden(userId);
1681
+ var dmList = dm.getDmList(userId);
1682
+ for (var i = 0; i < dmList.length; i++) {
1683
+ if (hidden.indexOf(dmList[i].otherUserId) === -1) message.dmConversations.push(dmList[i].otherUserId);
1684
+ }
1685
+ }
1686
+ }
1687
+ ws.send(JSON.stringify(message));
1688
+ }
1689
+
1616
1690
  // Debounced broadcast of projects_updated when presence changes
1617
1691
  // Sends per-user filtered project lists + server-wide user list
1618
1692
  var presenceTimer = null;
@@ -1621,12 +1695,7 @@ function createServer(opts) {
1621
1695
  presenceTimer = setTimeout(function () {
1622
1696
  presenceTimer = null;
1623
1697
  if (!users.isMultiUser()) {
1624
- broadcastAll({
1625
- type: "projects_updated",
1626
- projects: getProjects(),
1627
- projectCount: projects.size,
1628
- removedProjects: getRemovedProjects(),
1629
- });
1698
+ forEachAppClient(function (ws) { sendProjectsUpdatedToClient(ws); });
1630
1699
  return;
1631
1700
  }
1632
1701
  var serverUsers = getServerUsers();
@@ -1643,61 +1712,73 @@ function createServer(opts) {
1643
1712
  avatarCustom: p.avatarCustom || "",
1644
1713
  };
1645
1714
  });
1646
- // Build per-user filtered lists, send individually
1715
+ // Build per-user filtered lists, send individually to both project and
1716
+ // slug-less Home sockets. A Home fallback must replace, never retain,
1717
+ // the project cache from its previous authenticated project socket.
1647
1718
  var sentUsers = {};
1648
- projects.forEach(function (ctx) {
1649
- ctx.forEachClient(function (ws) {
1650
- var userId = ws._clayUser ? ws._clayUser.id : null;
1651
- var key = userId || "__anon__";
1652
- if (sentUsers[key]) {
1653
- // Already computed for this user, just send the cached msg
1654
- ws.send(sentUsers[key]);
1655
- return;
1656
- }
1657
- var filteredProjects = [];
1658
- projects.forEach(function (pCtx, s) {
1659
- var status = pCtx.getStatus();
1660
- if (userId && onGetProjectAccess) {
1661
- var access = onGetProjectAccess(s);
1662
- if (access && !access.error && !users.canAccessProject(userId, access)) return;
1663
- }
1664
- filteredProjects.push(status);
1665
- });
1666
- // Per-user DM data
1667
- var userDmFavorites = userId ? users.getDmFavorites(userId) : [];
1668
- var userDmHidden = userId ? users.getDmHidden(userId) : [];
1669
- var userDmConversations = [];
1670
- if (userId) {
1671
- var dmList = dm.getDmList(userId);
1672
- for (var di = 0; di < dmList.length; di++) {
1673
- if (userDmHidden.indexOf(dmList[di].otherUserId) === -1) {
1674
- userDmConversations.push(dmList[di].otherUserId);
1675
- }
1719
+ forEachAppClient(function (ws) {
1720
+ var userId = ws._clayUser ? ws._clayUser.id : null;
1721
+ var key = userId || "__anon__";
1722
+ if (sentUsers[key]) {
1723
+ // Already computed for this user, just send the cached msg
1724
+ if (ws.readyState === 1) ws.send(sentUsers[key]);
1725
+ return;
1726
+ }
1727
+ var filteredProjects = getVisibleProjects(userId);
1728
+ // Per-user DM data
1729
+ var userDmFavorites = userId ? users.getDmFavorites(userId) : [];
1730
+ var userDmHidden = userId ? users.getDmHidden(userId) : [];
1731
+ var userDmConversations = [];
1732
+ if (userId) {
1733
+ var dmList = dm.getDmList(userId);
1734
+ for (var di = 0; di < dmList.length; di++) {
1735
+ if (userDmHidden.indexOf(dmList[di].otherUserId) === -1) {
1736
+ userDmConversations.push(dmList[di].otherUserId);
1676
1737
  }
1677
1738
  }
1678
- var msgStr = JSON.stringify({
1679
- type: "projects_updated",
1680
- projects: filteredProjects,
1681
- projectCount: projects.size,
1682
- serverUsers: serverUsers,
1683
- allUsers: allUsers,
1684
- dmFavorites: userDmFavorites,
1685
- dmConversations: userDmConversations,
1686
- removedProjects: getRemovedProjects(userId),
1687
- });
1688
- sentUsers[key] = msgStr;
1689
- ws.send(msgStr);
1739
+ }
1740
+ var msgStr = JSON.stringify({
1741
+ type: "projects_updated",
1742
+ projects: filteredProjects,
1743
+ projectCount: filteredProjects.length,
1744
+ serverUsers: serverUsers,
1745
+ allUsers: allUsers,
1746
+ dmFavorites: userDmFavorites,
1747
+ dmConversations: userDmConversations,
1748
+ removedProjects: getRemovedProjects(userId),
1690
1749
  });
1750
+ sentUsers[key] = msgStr;
1751
+ if (ws.readyState === 1) ws.send(msgStr);
1691
1752
  });
1692
1753
  }, 300);
1693
1754
  }
1694
1755
 
1695
1756
  function broadcastAll(msg) {
1757
+ if (msg && msg.type === "projects_updated") {
1758
+ broadcastPresenceChange();
1759
+ return;
1760
+ }
1696
1761
  projects.forEach(function (ctx) {
1697
1762
  ctx.send(msg);
1698
1763
  });
1699
1764
  }
1700
1765
 
1766
+ function refreshProjectAccess() {
1767
+ if (!users.isMultiUser()) return;
1768
+ projects.forEach(function (ctx, slug) {
1769
+ if (!ctx || typeof ctx.forEachClient !== "function") return;
1770
+ ctx.forEachClient(function (ws) {
1771
+ var userId = ws._clayUser && ws._clayUser.id;
1772
+ if (userId && canUserAccessSlug(userId, slug)) return;
1773
+ try {
1774
+ if (ws.readyState === 1) ws.send(JSON.stringify({ type: "access_revoked", slug: slug }));
1775
+ ws.close(1008, "Project access revoked");
1776
+ } catch (e) {}
1777
+ });
1778
+ });
1779
+ broadcastPresenceChange();
1780
+ }
1781
+
1701
1782
  // Send a message to every live ws belonging to a specific user across all projects.
1702
1783
  // Used by user-targeted notifications (e.g. user-to-user @mentions).
1703
1784
  function sendToUser(userId, msg) {
@@ -1813,6 +1894,7 @@ function createServer(opts) {
1813
1894
  setRecovery: auth.setRecovery,
1814
1895
  clearRecovery: auth.clearRecovery,
1815
1896
  broadcastAll: broadcastAll,
1897
+ refreshProjectAccess: refreshProjectAccess,
1816
1898
  forEachProject: forEachProject,
1817
1899
  destroyProject: removeProject,
1818
1900
  destroyAll: destroyAll,
@@ -42,6 +42,9 @@ function attachPairFactory(ctx) {
42
42
  function validateVendor(vendor) {
43
43
  var installed = sm.installedVendors || [];
44
44
  if (!vendor || installed.indexOf(vendor) === -1) throw new Error("vendor is not installed: " + (vendor || "unknown"));
45
+ if (Array.isArray(sm.availableVendors) && sm.availableVendors.indexOf(vendor) === -1) {
46
+ throw new Error("vendor runtime is temporarily unavailable: " + vendor);
47
+ }
45
48
  return vendor;
46
49
  }
47
50
 
@@ -0,0 +1,40 @@
1
+ var MAX_RESPONSE_CHARS = 30000;
2
+
3
+ function responseText(history, fromIndex) {
4
+ var text = "";
5
+ for (var i = Math.max(0, fromIndex || 0); i < history.length; i++) {
6
+ if (history[i] && history[i].type === "delta" && history[i].text) text += history[i].text;
7
+ }
8
+ return text.length > MAX_RESPONSE_CHARS ? text.slice(-MAX_RESPONSE_CHARS) : text;
9
+ }
10
+
11
+ function errorSince(history, fromIndex) {
12
+ for (var i = history.length - 1; i >= Math.max(0, fromIndex || 0); i--) {
13
+ if (history[i] && history[i].type === "error") return history[i].text || "partner turn failed";
14
+ }
15
+ return null;
16
+ }
17
+
18
+ function recentTurns(session, count) {
19
+ var history = session.history || [];
20
+ var starts = [];
21
+ for (var i = 0; i < history.length; i++) if (history[i] && history[i].type === "user_message") starts.push(i);
22
+ var from = starts.length > 0 ? starts[Math.max(0, starts.length - count)] : 0;
23
+ var turns = [];
24
+ var current = null;
25
+ for (var j = from; j < history.length; j++) {
26
+ var item = history[j];
27
+ if (!item) continue;
28
+ if (item.type === "user_message") {
29
+ current = { user: item.text || "", delegated: !!item.delegated, response: "" };
30
+ turns.push(current);
31
+ } else if (item.type === "delta" && item.text) {
32
+ if (!current) { current = { user: "", delegated: false, response: "" }; turns.push(current); }
33
+ current.response += item.text;
34
+ if (current.response.length > MAX_RESPONSE_CHARS) current.response = current.response.slice(-MAX_RESPONSE_CHARS);
35
+ }
36
+ }
37
+ return turns;
38
+ }
39
+
40
+ module.exports = { errorSince: errorSince, recentTurns: recentTurns, responseText: responseText };
@@ -1,6 +1,12 @@
1
1
  // Partner-control tools for sessions that belong to a split group.
2
2
 
3
3
  var buildShape = require("./session-spawn-mcp-server").buildShape;
4
+ var z;
5
+ try { z = require("zod"); } catch (e) { z = null; }
6
+ var evaluationSchema = z ? z.union([
7
+ z.string(),
8
+ z.object({ outcome: z.string(), note: z.string().optional() }),
9
+ ]) : null;
4
10
 
5
11
  // `options.lifecycle` adds the autonomous management tools. They are omitted
6
12
  // for a plain side-by-side split, which has no Driver role to exercise them.
@@ -15,6 +21,7 @@ function getToolDefs(handlers, options) {
15
21
  wait: { type: "boolean", description: "Wait for the partner's turn to finish. Defaults to true." },
16
22
  timeoutSeconds: { type: "number", description: "Maximum wait in seconds, from 1 to 900. Defaults to 300." },
17
23
  operationId: { type: "string", description: "Optional stable id for this delegation. Reusing it within the same human turn returns the original operation instead of sending twice." },
24
+ taskId: { type: "string", description: "Optional stable task id for generation and completion correlation." },
18
25
  }, ["message"]),
19
26
  handler: function (args) { return handlers.send(args || {}); },
20
27
  },
@@ -29,7 +36,7 @@ function getToolDefs(handlers, options) {
29
36
  {
30
37
  name: "interrupt_partner",
31
38
  description: "Interrupt the Split Worker's current task. Use this when the task is going in the wrong direction, needs to be reprioritized, or must stop before the next instruction. The Split Worker returns any partial result to you for review.",
32
- inputSchema: buildShape({}),
39
+ inputSchema: buildShape({ reason: { type: "string", description: "Why the Driver is interrupting this task." } }),
33
40
  handler: function (args) { return handlers.interrupt(args || {}); },
34
41
  },
35
42
  {
@@ -41,6 +48,15 @@ function getToolDefs(handlers, options) {
41
48
  ];
42
49
  if (!lifecycle) return defs;
43
50
  return defs.concat([
51
+ {
52
+ name: "message_partner",
53
+ description: "Queue a non-interrupting message on the active Split Worker's runtime input without creating a delegated follow-up. Returns queued or rejected with an exact request id; queued does not claim model execution.",
54
+ inputSchema: buildShape({
55
+ message: { type: "string", description: "Bounded correction or context for the current active Worker turn." },
56
+ requestId: { type: "string", description: "Optional stable request id. Reuse it to deduplicate a retried delivery within this human turn." },
57
+ }, ["message"]),
58
+ handler: function (args) { return handlers.message(args || {}); },
59
+ },
44
60
  {
45
61
  name: "partner_status",
46
62
  description: "Bounded capacity and continuity report for your Split Worker, for deciding reuse against replacement: context tokens used and the ratio of its window, current task and activity, vendor/model/effort, history size and idle time, whether it is safe to replace right now, and your own recorded results for earlier Worker generations. Returns no transcript. Prefer this over reading turns when you only need to decide.",
@@ -58,10 +74,11 @@ function getToolDefs(handlers, options) {
58
74
  workerEffort: { type: "string", description: "Optional reasoning effort for the new Worker." },
59
75
  recommendationRationale: { type: "string", description: "Concise Driver-authored explanation of why the recommended replacement vendor, model, and effort fit the next task." },
60
76
  evaluation: {
61
- type: "object",
77
+ schema: evaluationSchema,
62
78
  description: "Optional bounded assessment of the Worker being replaced, recorded against that exact generation.",
63
79
  },
64
80
  operationId: { type: "string", description: "Optional stable id for this replacement. Reusing it within the same human turn returns the original operation instead of replacing twice." },
81
+ supersedeProposalId: { type: "string", description: "Exact pending replacement proposal id to supersede with this new configuration." },
65
82
  }, ["message", "recommendationRationale"]),
66
83
  handler: function (args) { return handlers.replace(args || {}); },
67
84
  },
@@ -0,0 +1,11 @@
1
+ function fingerprint(args, fields) {
2
+ var source = args || {};
3
+ var value = {};
4
+ for (var i = 0; i < fields.length; i++) {
5
+ var name = fields[i];
6
+ value[name] = Object.prototype.hasOwnProperty.call(source, name) ? source[name] : null;
7
+ }
8
+ return JSON.stringify(value);
9
+ }
10
+
11
+ module.exports = { fingerprint: fingerprint };
@@ -3,6 +3,19 @@
3
3
  // Pure data, extracted from project-session-pair.js so that module stays under
4
4
  // the size limit and so the guidance can be reviewed as prose.
5
5
 
6
+ var WORKER_RUNTIME_SELECTION = [
7
+ "Use a high-low model mix: keep decomposition, judgment, and review with the Driver, and prefer a lighter",
8
+ "Worker model capable of the bounded execution task. Do not mirror the Driver's model or default to the",
9
+ "strongest available model merely because it is available. Explicitly fill the vendor, model, and thinking",
10
+ "effort recommendation fields for both creation and replacement; never leave model selection to card defaults.",
11
+ "Select model capability and thinking effort separately. Start with the lowest effort adequate for the task,",
12
+ "not high effort by habit. Choose from the installed, offered catalog; do not invent model availability or prices.",
13
+ "Explain why the lighter choice can handle the task. Escalate only for a concrete requirement beyond its",
14
+ "capabilities or observed failures, and state that reason. Use recorded Worker outcomes when available,",
15
+ "and reconsider a lighter model for the next bounded task after an escalation. Honor the user's explicit",
16
+ "runtime choice; this is a recommendation policy, not a restriction on which model the user may select.",
17
+ ].join(" ").replace(/ {2,}/g, " ");
18
+
6
19
  var DRIVER_DELEGATION = [
7
20
  "Your management objective: protect your own context, keep the Split Worker compact, and put execution where",
8
21
  "it runs best. Delegate implementation-heavy work rather than doing it here. Treat work spanning multiple modules,",
@@ -13,9 +26,10 @@ var DRIVER_DELEGATION = [
13
26
  ].join(" ").replace(/ {2,}/g, " ");
14
27
 
15
28
  var DRIVER_CORE = [
29
+ WORKER_RUNTIME_SELECTION,
16
30
  "You are the Driver of a visible Driver/Split Worker pair, and you manage that Split Worker yourself.",
17
- "The tools send_to_partner, read_partner, partner_status, replace_partner, interrupt_partner, close_partner,",
18
- "and record_partner_evaluation are provided directly to you.",
31
+ "The pair tools distinguish a live message, a queued follow-up, an interrupt-and-replace task, resumption,",
32
+ "and replacement of the Worker runtime. Inspect exact task and proposal ids before changing queued work.",
19
33
  "",
20
34
  "Reuse the existing Split Worker",
21
35
  "only when its accumulated context genuinely helps the next task; when it is context-bloated, stale, or",
@@ -34,6 +48,8 @@ var DRIVER_CORE = [
34
48
  "interrupt true, which stops it first only after acceptance. A human Stop",
35
49
  "is authoritative: do not retry, send more work, or replace the Worker in the same turn. Clay blocks those",
36
50
  "actions until the human sends a new Driver message. Use close_partner when they ask to close the pane.",
51
+ "A Worker outcome envelope is Worker-reported evidence, not Driver verification. Preserve partial results and",
52
+ "correlate completions to their task and generation; never attach a late old-generation result to a new Worker.",
37
53
  "",
38
54
  "Recommend a Worker vendor, model, and effort from what is actually installed and offered, and include a concise",
39
55
  "rationale explaining why all three fit the task. The card remains visible as an audit trail even when full access",
@@ -56,7 +72,12 @@ var DRIVER_CORE = [
56
72
 
57
73
  var DRIVER = DRIVER_CORE + " " + DRIVER_DELEGATION;
58
74
 
75
+ function worker(taskId) {
76
+ return "You are the configured Split Worker. For task " + (taskId || "unknown") + ", call report_partner_outcome before finishing when the tool is available. Report only what you observed: changed files, verification commands/results, unverified work, running processes, and next action. Never claim Driver verification.";
77
+ }
78
+
59
79
  var UNPAIRED = [
80
+ WORKER_RUNTIME_SELECTION,
60
81
  "Infer the user's intended target from conversational and UI context. References to a user-visible paired or",
61
82
  "split pane, its session, its activity or status, or a collaborator the user wants opened resolve to Clay's",
62
83
  "Split Worker and its partner tools. Internal Sub-agents are a distinct execution mechanism, not a lexical",
@@ -76,4 +97,5 @@ module.exports = {
76
97
  DRIVER_CORE: DRIVER_CORE,
77
98
  DRIVER_DELEGATION: DRIVER_DELEGATION,
78
99
  UNPAIRED: UNPAIRED,
100
+ worker: worker,
79
101
  };
@@ -111,14 +111,19 @@ function attachPairTurnControl(ctx) {
111
111
  return clean;
112
112
  }
113
113
 
114
- function runOperation(driver, kind, rawId, fn) {
114
+ function runOperation(driver, kind, rawId, fn, fingerprint) {
115
115
  var id = operationId(rawId);
116
116
  if (!id) return Promise.resolve().then(fn);
117
117
  var state = stateFor(driver);
118
118
  var key = kind + ":" + id;
119
- if (state.operations[key]) return state.operations[key];
119
+ if (state.operations[key]) {
120
+ if (fingerprint !== undefined && state.operations[key].fingerprint !== fingerprint) {
121
+ return Promise.reject(new Error("operation id was already used with different input"));
122
+ }
123
+ return state.operations[key].promise;
124
+ }
120
125
  var promise = Promise.resolve().then(fn);
121
- state.operations[key] = promise;
126
+ state.operations[key] = { fingerprint: fingerprint, promise: promise };
122
127
  return promise;
123
128
  }
124
129
 
@@ -69,6 +69,7 @@ function restore(meta, session) {
69
69
  createdVia: stored.createdVia === "split-worker" ? "split-worker" : null,
70
70
  createdAt: typeof stored.createdAt === "number" && isFinite(stored.createdAt) ? stored.createdAt : null,
71
71
  };
72
+ session._pairGeneration = session.sessionProvenance.generation;
72
73
  return storedOriginId !== session.sessionOriginId || JSON.stringify(stored) !== JSON.stringify(session.sessionProvenance);
73
74
  }
74
75
 
@@ -12,7 +12,8 @@ function buildShape(props, required) {
12
12
  var key = keys[i];
13
13
  var prop = props[key];
14
14
  var field;
15
- if (prop.type === "number") field = z.number();
15
+ if (prop.schema) field = prop.schema;
16
+ else if (prop.type === "number") field = z.number();
16
17
  else if (prop.type === "boolean") field = z.boolean();
17
18
  else if (prop.enum) field = z.enum(prop.enum);
18
19
  else field = z.string();