clay-server 4.0.0-beta.2 → 4.0.0-beta.21

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 (177) hide show
  1. package/lib/background-task-timing.js +105 -0
  2. package/lib/capsule-display-floor.js +51 -0
  3. package/lib/capsule-frame-server.js +255 -0
  4. package/lib/capsule-mate-turn.js +105 -0
  5. package/lib/capsule-pig-logic.js +300 -0
  6. package/lib/capsule-server-runtimes.js +39 -0
  7. package/lib/capsules/pig/display.js +198 -0
  8. package/lib/capsules/pig/manifest.json +9 -0
  9. package/lib/capsules/pig/ui.json +84 -0
  10. package/lib/config.js +25 -1
  11. package/lib/daemon-projects.js +13 -8
  12. package/lib/daemon.js +127 -14
  13. package/lib/git-cli.js +95 -1
  14. package/lib/knowledge-import.js +436 -0
  15. package/lib/knowledge-record-store.js +188 -0
  16. package/lib/knowledge-search.js +240 -0
  17. package/lib/mate-knowledge-mcp-server.js +151 -0
  18. package/lib/mate-knowledge-migration.js +283 -0
  19. package/lib/mate-knowledge-service.js +488 -0
  20. package/lib/mate-knowledge-sync.js +482 -0
  21. package/lib/migrate-single-user.js +2 -0
  22. package/lib/notes-lifecycle.js +149 -0
  23. package/lib/notes.js +60 -3
  24. package/lib/os-users.js +3 -1
  25. package/lib/project-capsule-catalog.js +9 -1
  26. package/lib/project-capsule-turn.js +96 -0
  27. package/lib/project-connection.js +14 -1
  28. package/lib/project-http.js +15 -1
  29. package/lib/project-knowledge.js +22 -0
  30. package/lib/project-log-context.js +172 -0
  31. package/lib/project-log-feedback-delivery.js +118 -0
  32. package/lib/project-logs-comments.js +137 -0
  33. package/lib/project-logs-context-state.js +42 -0
  34. package/lib/project-logs-mcp-server.js +328 -0
  35. package/lib/project-logs-query.js +185 -0
  36. package/lib/project-logs-root.js +75 -0
  37. package/lib/project-logs-schema.js +259 -0
  38. package/lib/project-logs-service.js +463 -0
  39. package/lib/project-logs-snapshot.js +205 -0
  40. package/lib/project-logs-store.js +495 -0
  41. package/lib/project-logs-versioning.js +132 -0
  42. package/lib/project-logs.js +400 -0
  43. package/lib/project-mate-interaction.js +14 -0
  44. package/lib/project-mate-knowledge.js +110 -0
  45. package/lib/project-memory.js +22 -0
  46. package/lib/project-message-delivery.js +70 -0
  47. package/lib/project-pair-lifecycle.js +460 -0
  48. package/lib/project-session-handoff.js +1 -1
  49. package/lib/project-session-notes.js +64 -13
  50. package/lib/project-session-pair.js +159 -146
  51. package/lib/project-session-spawn.js +1 -1
  52. package/lib/project-sessions.js +58 -13
  53. package/lib/project-user-message.js +53 -4
  54. package/lib/project-vendor-login.js +20 -3
  55. package/lib/project-worker-permission.js +430 -0
  56. package/lib/project-worker-proposal.js +208 -62
  57. package/lib/project.js +178 -6
  58. package/lib/public/app.js +58 -35
  59. package/lib/public/css/admin.css +2 -2
  60. package/lib/public/css/capsule-ui.css +38 -0
  61. package/lib/public/css/command-palette.css +19 -9
  62. package/lib/public/css/git-placard.css +154 -0
  63. package/lib/public/css/home-hub.css +4 -0
  64. package/lib/public/css/home-session-actions.css +66 -0
  65. package/lib/public/css/home-sidebar.css +66 -0
  66. package/lib/public/css/input.css +163 -28
  67. package/lib/public/css/mobile-nav.css +80 -0
  68. package/lib/public/css/notifications-center.css +67 -0
  69. package/lib/public/css/pane.css +52 -0
  70. package/lib/public/css/project-logs.css +198 -0
  71. package/lib/public/css/scheduler.css +1 -1
  72. package/lib/public/css/session-actions.css +16 -101
  73. package/lib/public/css/sidebar.css +103 -0
  74. package/lib/public/css/sticky-notes.css +237 -229
  75. package/lib/public/css/worker-proposal.css +34 -1
  76. package/lib/public/index.html +45 -11
  77. package/lib/public/modules/admin.js +8 -8
  78. package/lib/public/modules/app-connection.js +109 -4
  79. package/lib/public/modules/app-home-hub.js +19 -7
  80. package/lib/public/modules/app-message-cards.js +2 -1
  81. package/lib/public/modules/app-messages.js +84 -7
  82. package/lib/public/modules/app-misc.js +2 -14
  83. package/lib/public/modules/app-notifications.js +11 -7
  84. package/lib/public/modules/app-panels.js +46 -4
  85. package/lib/public/modules/app-projects.js +13 -8
  86. package/lib/public/modules/app-rendering.js +1 -0
  87. package/lib/public/modules/background-tasks-ui.js +139 -16
  88. package/lib/public/modules/capsule-preference.js +44 -0
  89. package/lib/public/modules/chat-bubble-renderer.js +1 -1
  90. package/lib/public/modules/clay-log-links.js +70 -0
  91. package/lib/public/modules/command-palette.js +52 -15
  92. package/lib/public/modules/context-sources.js +0 -3
  93. package/lib/public/modules/git-agent-sessions.js +85 -0
  94. package/lib/public/modules/git-panel.js +111 -84
  95. package/lib/public/modules/git-placard.js +161 -0
  96. package/lib/public/modules/home-chat-empty-state.js +39 -0
  97. package/lib/public/modules/home-chat-identity.js +9 -0
  98. package/lib/public/modules/home-conversations-sheet.js +102 -48
  99. package/lib/public/modules/home-mate-chat.js +15 -35
  100. package/lib/public/modules/home-session-actions.js +6 -0
  101. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  102. package/lib/public/modules/home-sidebar.js +7 -0
  103. package/lib/public/modules/home-tool-frame.js +182 -0
  104. package/lib/public/modules/home-tools.js +138 -1
  105. package/lib/public/modules/input.js +38 -7
  106. package/lib/public/modules/markdown.js +2 -0
  107. package/lib/public/modules/mate-sidebar.js +0 -8
  108. package/lib/public/modules/message-delivery.js +97 -0
  109. package/lib/public/modules/paste-modal.js +84 -0
  110. package/lib/public/modules/project-logs-render.js +405 -0
  111. package/lib/public/modules/project-logs.js +461 -0
  112. package/lib/public/modules/project-removal-target.js +26 -0
  113. package/lib/public/modules/scheduler.js +17 -3
  114. package/lib/public/modules/session-actions.js +29 -105
  115. package/lib/public/modules/session-hierarchy.js +54 -0
  116. package/lib/public/modules/sidebar-mobile.js +40 -15
  117. package/lib/public/modules/sidebar-session-hierarchy.js +214 -0
  118. package/lib/public/modules/sidebar-sessions.js +77 -11
  119. package/lib/public/modules/sidebar.js +17 -5
  120. package/lib/public/modules/split-group-helpers.js +8 -0
  121. package/lib/public/modules/split-view.js +5 -2
  122. package/lib/public/modules/sticky-note-markdown.js +5 -0
  123. package/lib/public/modules/sticky-notes-browser.js +326 -0
  124. package/lib/public/modules/sticky-notes-card.js +360 -0
  125. package/lib/public/modules/sticky-notes-editor.js +291 -0
  126. package/lib/public/modules/sticky-notes-shared.js +90 -0
  127. package/lib/public/modules/sticky-notes.js +170 -946
  128. package/lib/public/modules/tool-palette-order.js +143 -0
  129. package/lib/public/modules/tool-palette-overlays.js +159 -0
  130. package/lib/public/modules/tool-palette.js +21 -188
  131. package/lib/public/modules/tools.js +21 -1
  132. package/lib/public/modules/update-snooze.js +169 -0
  133. package/lib/public/modules/user-settings.js +4 -0
  134. package/lib/public/modules/worker-pane-lock.js +176 -0
  135. package/lib/public/modules/worker-proposal-state.js +16 -0
  136. package/lib/public/modules/worker-proposal.js +49 -12
  137. package/lib/public/style.css +3 -1
  138. package/lib/sdk-bridge.js +132 -46
  139. package/lib/sdk-message-processor.js +60 -14
  140. package/lib/server-experimental-settings.js +49 -0
  141. package/lib/server-global-ws.js +11 -0
  142. package/lib/server-home-chat-events.js +31 -1
  143. package/lib/server-home-chat.js +27 -4
  144. package/lib/server-home-clay-entry.js +1 -1
  145. package/lib/server-home-clay-session-links.js +34 -5
  146. package/lib/server-settings.js +5 -0
  147. package/lib/server-tools.js +127 -9
  148. package/lib/server.js +124 -18
  149. package/lib/session-driver-eligibility.js +38 -0
  150. package/lib/session-driver-orchestration.js +31 -0
  151. package/lib/session-hygiene.js +3 -0
  152. package/lib/session-notes-mcp-server.js +25 -5
  153. package/lib/session-pair-factory.js +247 -0
  154. package/lib/session-pair-mcp-server.js +46 -8
  155. package/lib/session-pair-prompts.js +79 -0
  156. package/lib/session-pair-turn-control.js +153 -0
  157. package/lib/session-provenance.js +119 -0
  158. package/lib/session-spawn-mcp-server.js +1 -1
  159. package/lib/session-split-groups.js +4 -1
  160. package/lib/session-title-policy.js +60 -0
  161. package/lib/session-visibility.js +39 -0
  162. package/lib/sessions.js +61 -11
  163. package/lib/tools-registry.js +89 -10
  164. package/lib/update-snooze.js +437 -0
  165. package/lib/users-experimental-preferences.js +30 -0
  166. package/lib/users-permissions.js +2 -2
  167. package/lib/users.js +22 -16
  168. package/lib/workspace-query-access.js +102 -0
  169. package/lib/workspace-query-service.js +24 -17
  170. package/lib/worktree.js +25 -38
  171. package/lib/ws-schema.js +38 -7
  172. package/lib/yoke/adapters/claude-worker.js +16 -6
  173. package/lib/yoke/adapters/claude.js +81 -4
  174. package/lib/yoke/adapters/codex.js +63 -15
  175. package/lib/yoke/codex-background-tasks.js +8 -2
  176. package/lib/yoke/vendor-registry.js +1 -1
  177. package/package.json +3 -3
package/lib/server.js CHANGED
@@ -23,6 +23,8 @@ var serverPalette = require("./server-palette");
23
23
  var workspaceQueryModule = require("./workspace-query-service");
24
24
  var { attachWorkspaceQueryService } = workspaceQueryModule;
25
25
  var { attachWorkspaceAssignmentService } = require("./workspace-assignment-service");
26
+ var { attachProjectLogsService } = require("./project-logs-service");
27
+ var { attachMateKnowledgeService } = require("./mate-knowledge-service");
26
28
  var serverEmail = require("./server-email");
27
29
  var serverGlobalWs = require("./server-global-ws");
28
30
  var { parseWsRequestUrl } = require("./ws-request");
@@ -306,18 +308,50 @@ function createServer(opts) {
306
308
  var workspaceQueryService = attachWorkspaceQueryService({
307
309
  getProjects: function () { return projects; },
308
310
  isMultiUser: function () { return users.isMultiUser(); },
309
- canAccessProject: function (userId, status) {
310
- return users.canAccessProject(userId, {
311
- ownerId: status && status.projectOwnerId || null,
312
- visibility: status && status.visibility || "public",
313
- allowedUsers: status && status.allowedUsers || [],
314
- });
311
+ // Project access is resolved from the authoritative project record, not
312
+ // from getStatus(), which carries no visibility or allowedUsers fields and
313
+ // would otherwise default every project to public.
314
+ onGetProjectAccess: onGetProjectAccess,
315
+ canAccessProject: function (userId, access) {
316
+ return users.canAccessProject(userId, access) === true;
315
317
  },
316
318
  resolveMate: function (userId, mateId) {
317
319
  return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
318
320
  },
319
321
  assignmentService: assignmentService,
320
322
  });
323
+ // Project access is resolved from the authoritative project record, not from
324
+ // getStatus(), which carries no visibility or allowedUsers fields.
325
+ var projectLogsService = attachProjectLogsService({
326
+ getProjects: function () { return projects; },
327
+ isMultiUser: function () { return users.isMultiUser(); },
328
+ canAccessProject: function (userId, status) {
329
+ if (!status || !status.slug || !onGetProjectAccess) return false;
330
+ var access = onGetProjectAccess(status.slug);
331
+ if (!access || access.error) return false;
332
+ return users.canAccessProject(userId, access) === true;
333
+ },
334
+ resolveMate: function (userId, mateId) {
335
+ return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
336
+ },
337
+ findUserById: function (userId) { return users.findUserById(userId); },
338
+ });
339
+ // Mate Knowledge reads the synchronized Knowledge records. The Mate list is
340
+ // derived from the owning user alone, so a binding can never span users.
341
+ var mateKnowledgeService = attachMateKnowledgeService({
342
+ getProjects: function () { return projects; },
343
+ isMultiUser: function () { return users.isMultiUser(); },
344
+ resolveMate: function (userId, mateId) {
345
+ return mates.getMate(mates.buildMateCtx(users.isMultiUser() ? userId : null), mateId);
346
+ },
347
+ listMates: function (userId) {
348
+ var mateCtx = mates.buildMateCtx(users.isMultiUser() ? userId : null);
349
+ var all = mates.getAllMates(mateCtx) || [];
350
+ return all.map(function (mate) {
351
+ return { id: mate.id, name: mate.name || null, createdBy: mate.createdBy || null, dir: mates.getMateDir(mateCtx, mate.id) };
352
+ });
353
+ },
354
+ });
321
355
 
322
356
  // --- Admin module ---
323
357
  var admin = serverAdmin.attachAdmin({
@@ -351,6 +385,9 @@ function createServer(opts) {
351
385
  projects: projects,
352
386
  opts: opts,
353
387
  CONFIG_DIR: CONFIG_DIR,
388
+ onCapsulesPreferenceChanged: function (userId) {
389
+ if (toolsHandler) toolsHandler.refreshMateCatalogs(userId);
390
+ },
354
391
  });
355
392
 
356
393
  var palette = serverPalette.attachPalette({
@@ -388,16 +425,38 @@ function createServer(opts) {
388
425
  securityHeaders["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
389
426
  }
390
427
 
391
- function setSecurityHeaders(res) {
428
+ // Capsule rich-Display frames are served by a second listener on the same
429
+ // hostname but a different port, so frame-src cannot stay at the default
430
+ // 'self'. The allowance is computed from the request's own hostname with
431
+ // any port, never a wildcard host, and only for the scheme the app itself
432
+ // speaks. If the host header is unusable the CSP stays at 'self' and the
433
+ // frame silently falls back to the floor Display.
434
+ function frameSrcFor(req) {
435
+ var host = req && req.headers && typeof req.headers.host === "string" ? req.headers.host : "";
436
+ if (!host) return "'self'";
437
+ var scheme = tlsOptions ? "https" : "http";
438
+ var hostname;
439
+ try {
440
+ hostname = new URL(scheme + "://" + host).hostname;
441
+ } catch (error) {
442
+ return "'self'";
443
+ }
444
+ if (!hostname) return "'self'";
445
+ if (hostname.indexOf(":") !== -1) hostname = "[" + hostname + "]";
446
+ return "'self' " + scheme + "://" + hostname + ":*";
447
+ }
448
+
449
+ function setSecurityHeaders(res, req) {
392
450
  var keys = Object.keys(securityHeaders);
393
451
  for (var i = 0; i < keys.length; i++) {
394
452
  res.setHeader(keys[i], securityHeaders[keys[i]]);
395
453
  }
454
+ res.setHeader("Content-Security-Policy", securityHeaders["Content-Security-Policy"] + " frame-src " + frameSrcFor(req) + ";");
396
455
  }
397
456
 
398
457
  // --- HTTP handler ---
399
458
  var appHandler = function (req, res) {
400
- setSecurityHeaders(res);
459
+ setSecurityHeaders(res, req);
401
460
  var fullUrl = req.url.split("?")[0];
402
461
 
403
462
  // --- Auth routes (delegated to server-auth) ---
@@ -937,17 +996,24 @@ function createServer(opts) {
937
996
 
938
997
  // --- WebSocket ---
939
998
  var wss = new WebSocketServer({ noServer: true });
999
+ var homePreferencesHandler = null;
940
1000
 
941
1001
  // Slug-less /ws handler: lets a user with no projects yet load the regular
942
1002
  // app shell and create/add/clone their first one. Only knows about the
943
- // small set of bootstrap messages (browse_dir, add_project, create_project,
944
- // clone_project, ping).
1003
+ // small set of bootstrap messages, including the Home preferences required
1004
+ // to dismiss the workspace restoration screen.
945
1005
  var globalWs = serverGlobalWs.attachGlobalWs({
946
1006
  osUsers: osUsers,
947
1007
  usersModule: users,
948
1008
  onAddProject: onAddProject,
949
1009
  onCreateProject: onCreateProject,
950
1010
  onCloneProject: onCloneProject,
1011
+ onHomePreferenceMessage: function (ws, msg) {
1012
+ return homePreferencesHandler ? homePreferencesHandler.handleMessage(ws, msg) : false;
1013
+ },
1014
+ onAppMessage: function (ws, msg) {
1015
+ return handleDmMessage(ws, msg);
1016
+ },
951
1017
  });
952
1018
 
953
1019
  server.on("upgrade", function (req, socket, head) {
@@ -1155,6 +1221,24 @@ function createServer(opts) {
1155
1221
  }
1156
1222
 
1157
1223
  // --- Project management ---
1224
+ function refreshVendorAuthEverywhere(vendor, linuxUser) {
1225
+ var refreshes = [];
1226
+ projects.forEach(function (projectCtx) {
1227
+ if (!projectCtx || typeof projectCtx.refreshVendorAuth !== "function") return;
1228
+ try {
1229
+ refreshes.push(Promise.resolve(projectCtx.refreshVendorAuth(vendor, linuxUser)).catch(function (err) {
1230
+ console.error("[server] Cross-project " + vendor + " auth refresh failed:", err && err.message ? err.message : err);
1231
+ return false;
1232
+ }));
1233
+ } catch (err) {
1234
+ console.error("[server] Cross-project " + vendor + " auth refresh threw:", err && err.message ? err.message : err);
1235
+ }
1236
+ });
1237
+ return Promise.all(refreshes).then(function (results) {
1238
+ return results.some(function (refreshed) { return !!refreshed; });
1239
+ });
1240
+ }
1241
+
1158
1242
  function addProject(cwd, slug, title, icon, projectOwnerId, worktreeMeta, extraOpts) {
1159
1243
  if (projects.has(slug)) return false;
1160
1244
  var extra = extraOpts || {};
@@ -1164,6 +1248,7 @@ function createServer(opts) {
1164
1248
  title: title || null,
1165
1249
  icon: icon || null,
1166
1250
  projectOwnerId: projectOwnerId || null,
1251
+ projectKnowledgeId: extra.projectKnowledgeId || (worktreeMeta && worktreeMeta.projectKnowledgeId) || null,
1167
1252
  worktreeMeta: worktreeMeta || null,
1168
1253
  isMate: extra.isMate || false,
1169
1254
  mateId: extra.mateId || (extra.isMate ? path.basename(cwd) : null),
@@ -1174,6 +1259,8 @@ function createServer(opts) {
1174
1259
  return mateHandler.createReadyMateFromInterview(ws, userId, definition);
1175
1260
  },
1176
1261
  workspaceQueryService: workspaceQueryService,
1262
+ projectLogsService: projectLogsService,
1263
+ mateKnowledgeService: mateKnowledgeService,
1177
1264
  getProjectAccess: function () {
1178
1265
  return onGetProjectAccess ? onGetProjectAccess(slug) : { visibility: "public", ownerId: projectOwnerId || null };
1179
1266
  },
@@ -1327,6 +1414,7 @@ function createServer(opts) {
1327
1414
  onDmMessage: handleDmMessage,
1328
1415
  broadcastAll: broadcastAll,
1329
1416
  notificationsModule: _globalNotifications,
1417
+ refreshVendorAuthEverywhere: refreshVendorAuthEverywhere,
1330
1418
  getProject: function (s) { return projects.get(s) || null; },
1331
1419
  isUserOnline: isUserOnline,
1332
1420
  });
@@ -1342,6 +1430,10 @@ function createServer(opts) {
1342
1430
  return true;
1343
1431
  }
1344
1432
 
1433
+ function setProjectLogContextState(slug, status) {
1434
+ return projectLogsService.setContextState(slug, status);
1435
+ }
1436
+
1345
1437
  // --- DM message handler (delegated to server-dm + server-mates inline) ---
1346
1438
  var dmHandler = serverDm.attachDm({
1347
1439
  users: users,
@@ -1362,9 +1454,10 @@ function createServer(opts) {
1362
1454
  projects: projects,
1363
1455
  addProject: addProject,
1364
1456
  workspaceQueryService: workspaceQueryService,
1457
+ projectLogsService: projectLogsService,
1365
1458
  });
1366
1459
 
1367
- var homePreferencesHandler = serverHomePreferences.attachHomePreferences({
1460
+ homePreferencesHandler = serverHomePreferences.attachHomePreferences({
1368
1461
  users: users,
1369
1462
  projects: projects,
1370
1463
  });
@@ -1373,6 +1466,17 @@ function createServer(opts) {
1373
1466
  var toolsHandler = serverTools.attachTools({
1374
1467
  users: users,
1375
1468
  projects: projects,
1469
+ // The rich-Display frame listener must speak the same scheme as the app,
1470
+ // or the sandboxed frame would be blocked as mixed content under TLS.
1471
+ tlsOptions: tlsOptions,
1472
+ // Lets a Capsule turn handed to the mate seat wake the opponent Mate's
1473
+ // project, registering it first if it has never been opened.
1474
+ findMateProject: homeChatHandler.findMateProject,
1475
+ isCapsulesEnabled: function (userId) {
1476
+ if (users.isMultiUser()) return users.getCapsulesEnabled(userId);
1477
+ var daemonConfig = onGetDaemonConfig ? onGetDaemonConfig() : {};
1478
+ return daemonConfig.capsulesEnabled === true;
1479
+ },
1376
1480
  });
1377
1481
 
1378
1482
  // --- Mate handler ---
@@ -1383,13 +1487,14 @@ function createServer(opts) {
1383
1487
  }
1384
1488
 
1385
1489
  function handleDmMessage(ws, msg, routedProjectSlug) {
1386
- if (assignmentService.handleMessage(ws, msg, routedProjectSlug)) return;
1387
- if (dmHandler.handleMessage(ws, msg)) return;
1388
- if (mateHandler && mateHandler.handleMessage(ws, msg)) return;
1389
- if (emailHandler.handleMessage(ws, msg)) return;
1390
- if (homeChatHandler.handleMessage(ws, msg)) return;
1391
- if (homePreferencesHandler.handleMessage(ws, msg)) return;
1392
- if (toolsHandler.handleMessage(ws, msg)) return;
1490
+ if (assignmentService.handleMessage(ws, msg, routedProjectSlug)) return true;
1491
+ if (dmHandler.handleMessage(ws, msg)) return true;
1492
+ if (mateHandler && mateHandler.handleMessage(ws, msg)) return true;
1493
+ if (emailHandler.handleMessage(ws, msg)) return true;
1494
+ if (homeChatHandler.handleMessage(ws, msg)) return true;
1495
+ if (homePreferencesHandler.handleMessage(ws, msg)) return true;
1496
+ if (toolsHandler.handleMessage(ws, msg)) return true;
1497
+ return false;
1393
1498
  }
1394
1499
 
1395
1500
  function removeProject(slug) {
@@ -1683,6 +1788,7 @@ function createServer(opts) {
1683
1788
  forEachProject: forEachProject,
1684
1789
  destroyProject: removeProject,
1685
1790
  destroyAll: destroyAll,
1791
+ setProjectLogContextState: setProjectLogContextState,
1686
1792
  };
1687
1793
  }
1688
1794
 
@@ -0,0 +1,38 @@
1
+ // Driver capability for a visible Driver/Split Worker pair.
2
+ //
3
+ // Model and vendor choice belongs to the user. A session does not need to
4
+ // clear a model-tier threshold to coordinate a Split Worker; runtime catalog
5
+ // validation belongs to session-pair-factory.js and applies equally to every
6
+ // requested role. The only session-level exclusion here is structural: an
7
+ // embedded terminal has no project chat surface on which to host the Driver
8
+ // controls or the paired pane.
9
+
10
+ var sessionProvenance = require("./session-provenance");
11
+
12
+ function evaluateDriverSession(session) {
13
+ if (!session) {
14
+ return { ok: false, error: "No session was bound to this request." };
15
+ }
16
+ if (session.mode === "tui") {
17
+ return {
18
+ ok: false,
19
+ error: "An embedded terminal session cannot take the Driver role.",
20
+ };
21
+ }
22
+ if (sessionProvenance.isWorker(session)) {
23
+ return {
24
+ ok: false,
25
+ error: "A Split Worker session cannot take the Driver role.",
26
+ };
27
+ }
28
+ return { ok: true, error: null };
29
+ }
30
+
31
+ function isEligibleDriverSession(session) {
32
+ return evaluateDriverSession(session).ok;
33
+ }
34
+
35
+ module.exports = {
36
+ evaluateDriverSession: evaluateDriverSession,
37
+ isEligibleDriverSession: isEligibleDriverSession,
38
+ };
@@ -0,0 +1,31 @@
1
+ // Model-tier policy for proactive Split Worker orchestration.
2
+ //
3
+ // Structural Driver eligibility remains model-agnostic. This policy only
4
+ // decides whether a model should be prompted to judge when delegation helps.
5
+
6
+ var HIGH_TIER_PATTERNS = {
7
+ claude: [/(?:^|-)fable(?:-|$)/i, /(?:^|-)opus(?:-|$)/i],
8
+ codex: [/^gpt-6(?:-|$)/i, /^gpt-[0-9]+(?:\.[0-9]+)?-sol(?:-|$)/i],
9
+ };
10
+
11
+ function resolvedModel(session, sm) {
12
+ if (!session) return "";
13
+ var vendor = session.vendor || "claude";
14
+ var defaults = sm && sm.defaultModelByVendor || {};
15
+ return session.model || defaults[vendor] || "";
16
+ }
17
+
18
+ function isHighTierDriverSession(session, sm) {
19
+ if (!session) return false;
20
+ var patterns = HIGH_TIER_PATTERNS[session.vendor || "claude"] || [];
21
+ var model = resolvedModel(session, sm);
22
+ for (var i = 0; i < patterns.length; i++) {
23
+ if (patterns[i].test(model)) return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ module.exports = {
29
+ isHighTierDriverSession: isHighTierDriverSession,
30
+ resolvedModel: resolvedModel,
31
+ };
@@ -2,6 +2,8 @@
2
2
  // "New session" request and which abandoned blanks are safe to sweep.
3
3
  // Pure decision logic only -- deletion/switching stays in sessions.js.
4
4
 
5
+ var sessionProvenance = require("./session-provenance");
6
+
5
7
  var BLANK_SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
6
8
  var CLAUDE_INTERRUPTED_TEXT = "[Request interrupted by user]";
7
9
 
@@ -19,6 +21,7 @@ function isBlankSession(s) {
19
21
  && !s.bookmarked
20
22
  && !s.spawn
21
23
  && !s.loop
24
+ && !sessionProvenance.isWorker(s)
22
25
  && (s.mode || "gui") !== "tui"
23
26
  && s.terminalId == null;
24
27
  }
@@ -9,14 +9,18 @@ var MEMORY_CONTRACT =
9
9
  "Important exception for deferred defects: while doing code or technical work, actively create a sticky note when you discover a concrete defect, regression risk, security issue, or data-loss risk that is outside the current session goal and will remain unfixed when the turn ends. Do not wait for the user to ask. Include the observable evidence, affected component, likely impact, and a clear next action. Check active notes first when a duplicate is plausible. Do not create defect notes for speculation, general cleanup ideas, or problems you fixed in the current session. " +
10
10
  "Important exception for deferred proposals: when you propose work (a fix, follow-up, improvement, or next step) and the user defers it rather than declining it (\"let's do that later\", \"next time\", \"after this\"), actively create a sticky note before the topic moves on. Do not wait to be asked. Capture what was proposed, why it mattered, and the agreed timing if any. Deferred agreements scroll out of chat history quickly and are hard to track; the board is where they survive. If the user declines the idea outright, do not write a note. " +
11
11
  "Never create a note merely because work is important, lengthy, spans agents or restarts, or might help another agent. Do not record completed work, implementation details, test results, investigation logs, transient blockers, conversation summaries, or announcements of your own activity. When uncertain, do not write. " +
12
- "Updates are visible too: update only when durable state materially changes, and remove a note created by your session when it stops being useful instead of turning it into a completion log. Put a concise plain-text title on the first line, stay focused on one topic, and include only the context needed for future action.";
12
+ "Updates are visible too: update only when durable state materially changes. " +
13
+ "A note is an attention item, so finishing it means closing it, never erasing it: call close_note once the thing it is asking for is done, and the note leaves the active board while the record stays and can be reopened. Do not turn a note into a completion log instead of closing it. " +
14
+ "Put a concise plain-text title on the first line, stay focused on one topic, and include only the context needed for future action.";
13
15
 
14
16
  function getToolDefs(handlers) {
15
17
  return [
16
18
  {
17
19
  name: "list_notes",
18
- description: MEMORY_CONTRACT + " List the active notes before writing when you need to avoid duplicates or inspect full memory beyond the injected summary.",
19
- inputSchema: buildShape({}),
20
+ description: MEMORY_CONTRACT + " List the notes before writing when you need to avoid duplicates or inspect full memory beyond the injected summary. Lists open notes by default; pass state to see closed ones.",
21
+ inputSchema: buildShape({
22
+ state: { type: "string", enum: ["open", "closed", "all"], description: "Which notes to list. Defaults to open, the notes still asking for action." },
23
+ }),
20
24
  handler: function (args) { return handlers.list(args || {}); },
21
25
  },
22
26
  {
@@ -29,11 +33,27 @@ function getToolDefs(handlers) {
29
33
  }, ["text"]),
30
34
  handler: function (args) { return handlers.write(args || {}); },
31
35
  },
36
+ {
37
+ name: "close_note",
38
+ description: MEMORY_CONTRACT + " Close a note created by this same session once what it asked for is done. Closing is not deletion: the note leaves the active board, stays queryable under state closed, and can be reopened. Notes created by users or other sessions cannot be closed by this session.",
39
+ inputSchema: buildShape({
40
+ id: { type: "string", description: "Id of the note to close." },
41
+ }, ["id"]),
42
+ handler: function (args) { return handlers.close(args || {}); },
43
+ },
44
+ {
45
+ name: "reopen_note",
46
+ description: MEMORY_CONTRACT + " Reopen a closed note created by this same session when it turns out to still need action. The note returns to the active board unchanged.",
47
+ inputSchema: buildShape({
48
+ id: { type: "string", description: "Id of the note to reopen." },
49
+ }, ["id"]),
50
+ handler: function (args) { return handlers.reopen(args || {}); },
51
+ },
32
52
  {
33
53
  name: "remove_note",
34
- description: MEMORY_CONTRACT + " Remove a note created by this same session when it is no longer true. Notes created by users or other sessions cannot be removed.",
54
+ description: MEMORY_CONTRACT + " Deprecated: use close_note. This tool no longer deletes anything and now closes the note instead, so an older caller cannot destroy a record. Same ownership rule as close_note.",
35
55
  inputSchema: buildShape({
36
- id: { type: "string", description: "Id of the note to remove." },
56
+ id: { type: "string", description: "Id of the note to close. Nothing is deleted." },
37
57
  }, ["id"]),
38
58
  handler: function (args) { return handlers.remove(args || {}); },
39
59
  },
@@ -0,0 +1,247 @@
1
+ // Split-group pair creation.
2
+ //
3
+ // Extracted from project-session-pair.js, which operates a pair once it
4
+ // exists; this module is only about how one comes into being. Both the
5
+ // explicit "Add Split Worker", accepted proposal, and transactional replacement
6
+ // flows land here, so vendor validation, owner derivation and the group record
7
+ // are written in exactly one place.
8
+ //
9
+ // Ownership always comes from the connection or the Driver session, never from
10
+ // the incoming message.
11
+ //
12
+ // CONTEXT CONTRACT: this module reads `sm`, `splitStore`, `isMate`,
13
+ // `usersModule` and `sendTo` off its own argument, all at the top level.
14
+ // attachSessionPair's context already carries all five, so it is passed
15
+ // through unchanged. Re-wrapping it (it was once `{ sm, splitStore, ctx }`)
16
+ // leaves `isMate`, `usersModule` and `sendTo` undefined, which silently
17
+ // disables the Mate guard, breaks the pair_session_create reply, and throws
18
+ // "Cannot read properties of undefined (reading 'isMultiUser')" the moment a
19
+ // pair is created for an owned session — single-user installs escape it only
20
+ // because a Driver with no ownerId short-circuits that expression. Add any new
21
+ // dependency as a top-level field of that same context.
22
+ //
23
+ // Two ordering rules matter and are load-bearing:
24
+ //
25
+ // 1. Nothing is created until everything is validated. Driver capability and
26
+ // both runtime requests are settled first, so a rejected request leaves no
27
+ // orphan session behind. Any installed, available model may be the Driver;
28
+ // model choice is the user's rather than a server-side tier policy.
29
+ // 2. `preflightRuntime` is side-effect free, so a caller that is about to
30
+ // destroy something (replacement dissolving a live pair) can validate the
31
+ // replacement first and abort while the old pair is still intact.
32
+
33
+ var driverEligibility = require("./session-driver-eligibility");
34
+ var models = require("./project-models");
35
+ var yoke = require("./yoke");
36
+ var sessionProvenance = require("./session-provenance");
37
+
38
+ function attachPairFactory(ctx) {
39
+ var sm = ctx.sm;
40
+ var store = ctx.splitStore;
41
+
42
+ function validateVendor(vendor) {
43
+ var installed = sm.installedVendors || [];
44
+ if (!vendor || installed.indexOf(vendor) === -1) throw new Error("vendor is not installed: " + (vendor || "unknown"));
45
+ return vendor;
46
+ }
47
+
48
+ // An explicitly requested model must exist in the server's own catalog for
49
+ // that vendor, matched through the repo's canonical matcher so an alias, an
50
+ // id or a resolvedModel all count. Caller text is never taken on trust: if
51
+ // the catalog is not populated we cannot confirm the model, so an explicit
52
+ // request fails closed rather than being forwarded blindly.
53
+ function validateModel(vendor, model) {
54
+ var requested = typeof model === "string" ? model.trim() : "";
55
+ if (!requested) return null;
56
+ var catalog = (sm.modelsByVendor && sm.modelsByVendor[vendor]) || [];
57
+ if (catalog.length === 0) {
58
+ throw new Error("no models are available yet for vendor \"" + vendor +
59
+ "\", so model \"" + requested + "\" cannot be confirmed");
60
+ }
61
+ for (var i = 0; i < catalog.length; i++) {
62
+ if (models.modelEntryMatches(catalog[i], requested)) return requested;
63
+ }
64
+ throw new Error("model is not available for vendor \"" + vendor + "\": " + requested);
65
+ }
66
+
67
+ function validateEffort(vendor, effort) {
68
+ var requested = typeof effort === "string" ? effort.trim() : "";
69
+ var fallback = (sm.currentEffortByVendor && sm.currentEffortByVendor[vendor]) || sm.currentEffort || "medium";
70
+ var clamped = yoke.clampEffort(vendor, requested || fallback) || null;
71
+ if (requested && !clamped) {
72
+ throw new Error("reasoning effort is not supported for vendor \"" + vendor + "\": " + requested);
73
+ }
74
+ return clamped;
75
+ }
76
+
77
+ // Resolve and validate one session runtime request without creating
78
+ // anything. Throws on the first unusable field.
79
+ function preflightRuntime(spec, defaultVendor) {
80
+ var request = spec || {};
81
+ var vendor = validateVendor((typeof request.vendor === "string" && request.vendor.trim()) || defaultVendor);
82
+ return {
83
+ vendor: vendor,
84
+ model: validateModel(vendor, request.model),
85
+ effort: validateEffort(vendor, request.effort),
86
+ explicitEffort: !!(typeof request.effort === "string" && request.effort.trim()),
87
+ };
88
+ }
89
+
90
+ // The default Worker vendor for an accepted proposal or replacement prefers
91
+ // a different engine from the Driver's own, so the pair does not double up.
92
+ function defaultWorkerVendorFor(driver) {
93
+ var installed = sm.installedVendors || [];
94
+ if (installed.length === 0) throw new Error("no coding agent is installed for a Split Worker session");
95
+ if (installed.indexOf("codex") !== -1 && driver.vendor !== "codex") return "codex";
96
+ for (var i = 0; i < installed.length; i++) {
97
+ if (installed[i] !== driver.vendor) return installed[i];
98
+ }
99
+ return installed[0];
100
+ }
101
+
102
+ // Side-effect-free check that this exact Driver could create this exact
103
+ // Worker right now. Used by replacement before it touches anything.
104
+ function preflightWorkerForDriver(driver, args) {
105
+ if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
106
+ if (!driver) throw new Error("driver session not found");
107
+ var verdict = driverEligibility.evaluateDriverSession(driver, sm);
108
+ if (!verdict.ok) throw new Error(verdict.error);
109
+ var options = args || {};
110
+ var vendor = typeof options.workerVendor === "string" ? options.workerVendor.trim() : "";
111
+ return preflightRuntime({
112
+ vendor: vendor || defaultWorkerVendorFor(driver),
113
+ model: options.workerModel,
114
+ effort: options.workerEffort,
115
+ }, null);
116
+ }
117
+
118
+ function createPairRecord(ws, msg) {
119
+ if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
120
+ var driverSpec = msg.driver || {};
121
+ var workerSpec = msg.worker || {};
122
+ var ownerId = ws._clayUser && ctx.usersModule.isMultiUser() ? ws._clayUser.id : null;
123
+
124
+ // --- Validate everything first; create nothing yet. ---
125
+
126
+ var workerRuntime = preflightRuntime(workerSpec, sm.lastVendor || "codex");
127
+
128
+ var existingDriver = null;
129
+ var driverRuntime = null;
130
+ var groupName;
131
+ if (Number.isInteger(driverSpec.sessionId)) {
132
+ // "Add Split Worker" on an existing session: the current session becomes
133
+ // the Driver with its full conversation context intact.
134
+ existingDriver = sm.sessions.get(driverSpec.sessionId);
135
+ if (!existingDriver) throw new Error("driver session not found");
136
+ if ((existingDriver.ownerId || null) !== ownerId) throw new Error("driver session access denied");
137
+ if (store.groupForMember(existingDriver.localId)) throw new Error("this session is already in a split group");
138
+ var existingVerdict = driverEligibility.evaluateDriverSession(existingDriver, sm);
139
+ if (!existingVerdict.ok) throw new Error(existingVerdict.error);
140
+ groupName = undefined; // auto name from member titles
141
+ } else {
142
+ driverRuntime = preflightRuntime(driverSpec, "claude");
143
+ groupName = "Agent pair";
144
+ }
145
+
146
+ // --- Everything is valid; now create. ---
147
+
148
+ var driver = existingDriver;
149
+ var createdDriver = null;
150
+ if (!driver) {
151
+ driver = sm.createSessionRaw({
152
+ ownerId: ownerId,
153
+ vendor: driverRuntime.vendor,
154
+ model: driverRuntime.model,
155
+ effort: driverRuntime.effort,
156
+ });
157
+ createdDriver = driver;
158
+ driver.title = "Driver · " + ((yoke.getVendorInfo(driverRuntime.vendor) || {}).displayName || driverRuntime.vendor);
159
+ if (driverRuntime.explicitEffort) driver.loopSettings = { effort: driverRuntime.effort };
160
+ }
161
+
162
+ var worker = sm.createSessionRaw({
163
+ ownerId: ownerId,
164
+ vendor: workerRuntime.vendor,
165
+ model: workerRuntime.model,
166
+ effort: workerRuntime.effort,
167
+ });
168
+ worker.title = "Split Worker · " + ((yoke.getVendorInfo(workerRuntime.vendor) || {}).displayName || workerRuntime.vendor);
169
+ if (workerRuntime.explicitEffort) worker.loopSettings = { effort: workerRuntime.effort };
170
+
171
+ var result;
172
+ try {
173
+ // Creation provenance is durable and independent from the active split
174
+ // group. Persist both ends before the group's broadcast so the Worker
175
+ // never flashes as unrelated and survives a restart with its parentage.
176
+ sessionProvenance.markWorker(driver, worker, sm.sessions);
177
+ if (typeof sm.saveSessionFile === "function") {
178
+ sm.saveSessionFile(driver);
179
+ sm.saveSessionFile(worker);
180
+ }
181
+
182
+ result = store.create(ws, {
183
+ members: [driver.localId, worker.localId],
184
+ pair: { driverId: driver.localId, workerId: worker.localId },
185
+ name: groupName,
186
+ });
187
+ if (!result.ok) throw new Error(result.error);
188
+ } catch (e) {
189
+ // Any failure after session creation must remove exactly the sessions
190
+ // made by this call. An existing Driver is never removed.
191
+ removeCreated(worker);
192
+ removeCreated(createdDriver);
193
+ throw e;
194
+ }
195
+ sm.broadcastSessionList();
196
+ return { driver: driver, worker: worker, group: result.group };
197
+ }
198
+
199
+ function removeCreated(session) {
200
+ if (!session) return;
201
+ if (typeof sm.deleteSessionQuiet === "function") {
202
+ try { sm.deleteSessionQuiet(session.localId); return; } catch (e) {}
203
+ }
204
+ try { sm.sessions.delete(session.localId); } catch (e) {}
205
+ }
206
+
207
+ function createWorkerForDriver(driver, args) {
208
+ // Validates the Driver's capability and the whole Worker runtime before
209
+ // any session is made, so a rejection creates nothing.
210
+ var runtime = preflightWorkerForDriver(driver, args);
211
+ var ws = {
212
+ _clayActiveSession: driver.localId,
213
+ _clayUser: driver.ownerId ? { id: driver.ownerId } : null,
214
+ };
215
+ var created = createPairRecord(ws, {
216
+ driver: { sessionId: driver.localId },
217
+ worker: {
218
+ vendor: runtime.vendor,
219
+ model: runtime.model || "",
220
+ effort: runtime.explicitEffort ? (args && args.workerEffort) || "" : "",
221
+ },
222
+ });
223
+ sm.sendToSession(driver, { type: "pair_session_created", ok: true, group: created.group });
224
+ return created;
225
+ }
226
+
227
+ function createPair(ws, msg) {
228
+ try {
229
+ var created = createPairRecord(ws, msg);
230
+ ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: created.group });
231
+ } catch (e) {
232
+ ctx.sendTo(ws, { type: "pair_session_created", ok: false, error: e.message || String(e) });
233
+ }
234
+ }
235
+
236
+ return {
237
+ createPair: createPair,
238
+ createPairRecord: createPairRecord,
239
+ createWorkerForDriver: createWorkerForDriver,
240
+ preflightRuntime: preflightRuntime,
241
+ preflightWorkerForDriver: preflightWorkerForDriver,
242
+ validateModel: validateModel,
243
+ validateVendor: validateVendor,
244
+ };
245
+ }
246
+
247
+ module.exports = { attachPairFactory: attachPairFactory };