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
@@ -1,20 +1,19 @@
1
1
  var pairMcp = require("./session-pair-mcp-server");
2
+ var pairPrompts = require("./session-pair-prompts");
3
+ var { attachPairFactory } = require("./session-pair-factory");
4
+ var { attachPairLifecycle } = require("./project-pair-lifecycle");
5
+ var { attachPairTurnControl } = require("./session-pair-turn-control");
6
+ var driverEligibility = require("./session-driver-eligibility"), driverOrchestration = require("./session-driver-orchestration");
7
+ var { attachWorkerPermission } = require("./project-worker-permission");
2
8
  var { attachWorkerProposal } = require("./project-worker-proposal");
3
- var yoke = require("./yoke");
4
-
5
9
  var MAX_RESPONSE_CHARS = 30000;
6
-
7
- function toolResult(value) {
8
- return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
9
- }
10
-
10
+ function toolResult(value) { return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] }); }
11
11
  function toolError(err) {
12
12
  return Promise.resolve({
13
13
  content: [{ type: "text", text: "Error: " + (err.message || err) }],
14
14
  isError: true,
15
15
  });
16
16
  }
17
-
18
17
  function responseText(history, fromIndex) {
19
18
  var text = "";
20
19
  for (var i = Math.max(0, fromIndex || 0); i < history.length; i++) {
@@ -59,11 +58,17 @@ function attachSessionPair(ctx) {
59
58
  var sm = ctx.sm;
60
59
  var store = ctx.splitStore;
61
60
  var workerProposal;
62
-
63
- function groupAndPartner(caller) {
61
+ var workerPermission;
62
+ var lifecycle;
63
+ var turnControl = attachPairTurnControl({ sm: sm, splitStore: store });
64
+ function groupAndPartner(caller, missingPairMessage) {
64
65
  if (!caller) throw new Error("partner tools require a session-bound tool server");
66
+ // Captured handlers must remain bound to the exact live Session object.
67
+ if (sm.sessions.get(caller.localId) !== caller) {
68
+ throw new Error("this session is no longer live; the partner tools are bound to an exact session");
69
+ }
65
70
  var group = store.groupForMember(caller.localId);
66
- if (!group) throw new Error("this session is not in a split group");
71
+ if (!group) throw new Error(missingPairMessage || "this session is not in a split group");
67
72
  if (group.pair && group.pair.driverId !== caller.localId) {
68
73
  throw new Error("only the configured Driver can direct this pair");
69
74
  }
@@ -93,27 +98,19 @@ function attachSessionPair(ctx) {
93
98
  broadcastDelegation(group, caller, partner, false);
94
99
  }
95
100
 
96
- function resumeDriverWithResult(caller, partner, token) {
97
- if (!token.detached || token.delivered || caller.destroying) return false;
98
- token.delivered = true;
99
- var failure = token.failure || null;
100
- var response = token.response || "";
101
- var text;
102
- if (token.interrupted) {
103
- text = "[Split Worker execution interrupted] The user interrupted the Split Worker mid-turn. Its work is PARTIAL and unverified — do not treat it as finished. Review what was done and decide next steps with the user.";
104
- } else {
105
- text = "A Split Worker task delegated through send_to_partner has finished.\n\n" +
106
- "Original task:\n" + token.message + "\n\n" +
107
- (failure ? "Split Worker error:\n" + failure : "Split Worker result:\n" + (response || "(No text response was recorded.)")) +
108
- "\n\nReview the result, verify it as needed, and continue the task.";
109
- }
110
- sm.sendAndRecord(caller, {
101
+ function resumeDriverWithMessage(caller, text, meta) {
102
+ if (!caller || caller.destroying) return false;
103
+ if (turnControl.blockedReason(caller)) return false;
104
+ var record = {
111
105
  type: "user_message",
112
106
  text: text,
113
107
  _internal: true,
114
- partnerResult: true,
115
- partnerSessionId: partner.localId,
116
- });
108
+ };
109
+ if (meta) {
110
+ var keys = Object.keys(meta);
111
+ for (var i = 0; i < keys.length; i++) record[keys[i]] = meta[keys[i]];
112
+ }
113
+ sm.sendAndRecord(caller, record);
117
114
  caller.lastActivity = Date.now();
118
115
  var sdk = ctx.getSdk();
119
116
  if (!sdk) {
@@ -138,6 +135,33 @@ function attachSessionPair(ctx) {
138
135
  return true;
139
136
  }
140
137
 
138
+ function resumeDriverWithResult(caller, partner, token) {
139
+ if (!token.detached || token.delivered || caller.destroying) return false;
140
+ token.delivered = true;
141
+ var failure = token.failure || null;
142
+ var response = token.response || "";
143
+ var text;
144
+ if (token.interrupted) {
145
+ text = "[Split Worker execution interrupted] The user interrupted the Split Worker mid-turn. Its work is PARTIAL and unverified — do not treat it as finished. Review what was done and decide next steps with the user.";
146
+ } else {
147
+ text = "A Split Worker task delegated through send_to_partner has finished.\n\n" +
148
+ "Original task:\n" + token.message + "\n\n" +
149
+ (failure ? "Split Worker error:\n" + failure : "Split Worker result:\n" + (response || "(No text response was recorded.)")) +
150
+ "\n\nReview the result, verify it as needed, and continue the task.";
151
+ }
152
+ return resumeDriverWithMessage(caller, text, {
153
+ partnerResult: true,
154
+ partnerSessionId: partner.localId,
155
+ });
156
+ }
157
+
158
+ function requestDetach(worker) {
159
+ var token = worker && worker._pairDelegation;
160
+ if (!token || token.detached || token.detachRequested) return false;
161
+ token.detachRequested = true;
162
+ return true;
163
+ }
164
+
141
165
  function handleTurnDone(partner) {
142
166
  var token = partner && partner._pairDelegation;
143
167
  if (!token) return false;
@@ -179,7 +203,8 @@ function attachSessionPair(ctx) {
179
203
  });
180
204
  return;
181
205
  }
182
- if (Date.now() >= deadline) {
206
+ // Timeouts and permission requests both detach into the push-back path.
207
+ if (Date.now() >= deadline || token.detachRequested) {
183
208
  clearInterval(timer);
184
209
  token.detached = true;
185
210
  monitorPartner(group, caller, partner, token);
@@ -203,13 +228,11 @@ function attachSessionPair(ctx) {
203
228
  async function sendToPartner(args, caller) {
204
229
  try {
205
230
  if (caller && caller._delegatedBy) throw new Error("delegated turns cannot delegate to another session");
231
+ var resolved = groupAndPartner(caller,
232
+ "send_to_partner requires an exact existing Driver/Split Worker pair; call propose_worker while unpaired");
206
233
  var message = typeof args.message === "string" ? args.message.trim() : "";
207
234
  if (!message) throw new Error("message is required");
208
- var created = null;
209
- if (caller && !store.groupForMember(caller.localId)) {
210
- created = createWorkerForDriver(caller, args);
211
- }
212
- var resolved = groupAndPartner(caller);
235
+ turnControl.assertWorkerAction(caller);
213
236
  var wait = args.wait !== false;
214
237
  var timeout = Number.isFinite(args.timeoutSeconds) ? Math.floor(args.timeoutSeconds) : 300;
215
238
  timeout = Math.max(1, Math.min(900, timeout));
@@ -247,13 +270,18 @@ function attachSessionPair(ctx) {
247
270
  if (!wait) {
248
271
  token.detached = true;
249
272
  monitorPartner(resolved.group, caller, partner, token);
250
- return toolResult({ status: "running", partnerId: partner.localId, workerCreated: !!created, hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
273
+ return toolResult({ status: "running", partnerId: partner.localId, hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
251
274
  }
252
- var completed = await waitForPartner(resolved.group, caller, partner, token, timeout);
253
- if (created) {
254
- completed.workerCreated = true;
255
- completed.partnerId = partner.localId;
275
+ if (!partner.isProcessing && !partner._queryStarting) {
276
+ finishDelegation(resolved.group, caller, partner, token);
277
+ var immediateFailure = errorSince(partner.history || [], token.startIndex);
278
+ return toolResult({
279
+ status: immediateFailure ? "error" : "complete",
280
+ response: responseText(partner.history || [], token.startIndex),
281
+ error: immediateFailure || undefined,
282
+ });
256
283
  }
284
+ var completed = await waitForPartner(resolved.group, caller, partner, token, timeout);
257
285
  return toolResult(completed);
258
286
  } catch (e) {
259
287
  if (caller) {
@@ -274,13 +302,15 @@ function attachSessionPair(ctx) {
274
302
  try {
275
303
  var resolved = groupAndPartner(caller);
276
304
  var count = Number.isFinite(args.lastTurns) ? Math.floor(args.lastTurns) : 1;
277
- count = Math.max(1, Math.min(5, count));
278
- return toolResult({
305
+ count = Math.max(0, Math.min(5, count));
306
+ var payload = {
279
307
  status: resolved.partner.isProcessing ? "running" : (resolved.partner._lastTurnInterrupted ? "interrupted" : "idle"),
280
308
  partnerId: resolved.partner.localId,
281
309
  title: resolved.partner.title || "New Session",
282
- turns: recentTurns(resolved.partner, count),
283
- });
310
+ turns: count > 0 ? recentTurns(resolved.partner, count) : [],
311
+ };
312
+ payload.capacity = lifecycle.optionalStatus(caller);
313
+ return toolResult(payload);
284
314
  } catch (e) {
285
315
  return toolError(e);
286
316
  }
@@ -311,6 +341,7 @@ function attachSessionPair(ctx) {
311
341
  if (partner.abortController) partner.abortController.abort();
312
342
  }
313
343
  if (partner._pairDelegation) finishDelegation(resolved.group, caller, partner, partner._pairDelegation);
344
+ workerPermission.cancelForSession(partner, "The Driver closed the Split Worker pair.");
314
345
  var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
315
346
  var result = store.dissolve(ws, { id: resolved.group.id });
316
347
  if (!result.ok) throw new Error(result.error || "could not close the Worker pair");
@@ -320,115 +351,85 @@ function attachSessionPair(ctx) {
320
351
  }
321
352
  }
322
353
 
354
+ function handleHumanStop(session) {
355
+ var roles = turnControl.markHumanStop(session);
356
+ if (!roles) return false;
357
+ workerPermission.cancelForSession(roles.worker, "The human stopped this Split Worker turn.");
358
+ return true;
359
+ }
360
+
323
361
  function getToolDefs(boundSession) {
324
362
  if (!boundSession) return pairMcp.getToolDefs({
325
363
  send: function () { return toolError(new Error("send_to_partner requires a session-bound tool server")); },
326
364
  read: function () { return toolError(new Error("read_partner requires a session-bound tool server")); },
327
365
  interrupt: function () { return toolError(new Error("interrupt_partner requires a session-bound tool server")); },
328
366
  close: function () { return toolError(new Error("close_partner requires a session-bound tool server")); },
367
+ status: function () { return toolError(new Error("partner_status requires a session-bound tool server")); },
368
+ replace: function () { return toolError(new Error("replace_partner requires a session-bound tool server")); },
369
+ evaluate: function () { return toolError(new Error("record_partner_evaluation requires a session-bound tool server")); },
329
370
  });
330
- // Mount whenever we have a bound session: MCP servers are fixed for the
331
- // lifetime of a query, and Claude queries live across turns, so gating on
332
- // group membership HERE would permanently hide the tools from a session
333
- // whose split is created mid-conversation (the ad-hoc flow). Handlers
334
- // re-resolve the group on every call, so an ungrouped session gets a
335
- // clear error, and a session that gains a partner later just works.
336
- // The one structural exclusion: a session that is ALREADY a configured
337
- // pair worker at query start never sees the tools.
338
371
  var group = store.groupForMember(boundSession.localId);
339
372
  if (group && group.pair && group.pair.driverId !== boundSession.localId) return [];
373
+ var adHocSplit = !!(group && !group.pair);
374
+ if (!driverEligibility.isEligibleDriverSession(boundSession, sm)) return [];
375
+ var persistentCatalog = boundSession.vendor === "codex" && (!group || !!group.pair);
376
+ if (!group && !persistentCatalog) {
377
+ return workerProposal.getToolDefs(boundSession)
378
+ .concat(workerPermission.getToolDefs(boundSession, { dormantDriver: true }));
379
+ }
380
+ var lifecycleHandlers = lifecycle.toolHandlers(boundSession);
340
381
  var tools = pairMcp.getToolDefs({
341
- send: function (args) { return sendToPartner(args, boundSession); },
382
+ send: function (args) {
383
+ return turnControl.runOperation(boundSession, "send", args && args.operationId, function () {
384
+ return sendToPartner(args || {}, boundSession);
385
+ });
386
+ },
342
387
  read: function (args) { return readPartner(args, boundSession); },
343
388
  interrupt: function (args) { return interruptPartner(args, boundSession); },
344
389
  close: function (args) { return closePartner(args, boundSession); },
345
- });
346
- return tools.concat(workerProposal.getToolDefs(boundSession));
347
- }
348
-
349
- function validateVendor(vendor) {
350
- var installed = sm.installedVendors || [];
351
- if (!vendor || installed.indexOf(vendor) === -1) throw new Error("vendor is not installed: " + (vendor || "unknown"));
352
- return vendor;
353
- }
354
-
355
- function createPairRecord(ws, msg) {
356
- if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
357
- var driverSpec = msg.driver || {};
358
- var workerSpec = msg.worker || {};
359
- var workerVendor = validateVendor(workerSpec.vendor || sm.lastVendor || "codex");
360
- var ownerId = ws._clayUser && ctx.usersModule.isMultiUser() ? ws._clayUser.id : null;
361
- var driver;
362
- var groupName;
363
- if (Number.isInteger(driverSpec.sessionId)) {
364
- // "Add Split Worker" on an existing session: the current session becomes
365
- // the Driver with its full conversation context intact.
366
- driver = sm.sessions.get(driverSpec.sessionId);
367
- if (!driver) throw new Error("driver session not found");
368
- if ((driver.ownerId || null) !== ownerId) throw new Error("driver session access denied");
369
- if (store.groupForMember(driver.localId)) throw new Error("this session is already in a split group");
370
- groupName = undefined; // auto name from member titles
371
- } else {
372
- var driverVendor = validateVendor(driverSpec.vendor || "claude");
373
- var driverEffort = yoke.clampEffort(driverVendor, driverSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[driverVendor]) || sm.currentEffort || "medium") || null;
374
- driver = sm.createSessionRaw({ ownerId: ownerId, vendor: driverVendor, model: driverSpec.model || null, effort: driverEffort });
375
- driver.title = "Driver · " + ((yoke.getVendorInfo(driverVendor) || {}).displayName || driverVendor);
376
- if (driverSpec.effort) driver.loopSettings = { effort: driverSpec.effort };
377
- groupName = "Agent pair";
390
+ status: lifecycleHandlers.status,
391
+ replace: function (args) {
392
+ return turnControl.runOperation(boundSession, "replace", args && args.operationId, function () {
393
+ return workerProposal.proposeReplacement(args || {}, boundSession);
394
+ });
395
+ },
396
+ evaluate: lifecycleHandlers.evaluate,
397
+ }, { lifecycle: persistentCatalog || !adHocSplit });
398
+ if (persistentCatalog) {
399
+ return workerProposal.getToolDefs(boundSession, { persistent: true })
400
+ .concat(tools)
401
+ .concat(workerPermission.getToolDefs(boundSession, { dormantDriver: true }));
378
402
  }
379
- var workerEffort = yoke.clampEffort(workerVendor, workerSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[workerVendor]) || sm.currentEffort || "medium") || null;
380
- var worker = sm.createSessionRaw({ ownerId: ownerId, vendor: workerVendor, model: workerSpec.model || null, effort: workerEffort });
381
- worker.title = "Split Worker · " + ((yoke.getVendorInfo(workerVendor) || {}).displayName || workerVendor);
382
- if (workerSpec.effort) worker.loopSettings = { effort: workerSpec.effort };
383
- var result = store.create(ws, {
384
- members: [driver.localId, worker.localId],
385
- pair: { driverId: driver.localId, workerId: worker.localId },
386
- name: groupName,
387
- });
388
- if (!result.ok) throw new Error(result.error);
389
- sm.broadcastSessionList();
390
- return { driver: driver, worker: worker, group: result.group };
403
+ return tools
404
+ .concat(workerPermission.getToolDefs(boundSession, { dormantDriver: false }));
391
405
  }
392
406
 
393
- function createWorkerForDriver(driver, args) {
394
- if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
395
- var installed = sm.installedVendors || [];
396
- if (installed.length === 0) throw new Error("no coding agent is installed for a Split Worker session");
397
- var vendor = typeof args.workerVendor === "string" ? args.workerVendor.trim() : "";
398
- if (vendor) validateVendor(vendor);
399
- if (!vendor) {
400
- if (installed.indexOf("codex") !== -1 && driver.vendor !== "codex") vendor = "codex";
401
- else {
402
- vendor = installed[0];
403
- for (var i = 0; i < installed.length; i++) {
404
- if (installed[i] !== driver.vendor) { vendor = installed[i]; break; }
405
- }
406
- }
407
- }
408
- var ws = {
409
- _clayActiveSession: driver.localId,
410
- _clayUser: driver.ownerId ? { id: driver.ownerId } : null,
411
- };
412
- var created = createPairRecord(ws, {
413
- driver: { sessionId: driver.localId },
414
- worker: {
415
- vendor: vendor,
416
- model: typeof args.workerModel === "string" ? args.workerModel : "",
417
- effort: typeof args.workerEffort === "string" ? args.workerEffort : "",
418
- },
419
- });
420
- sm.sendToSession(driver, { type: "pair_session_created", ok: true, group: created.group });
421
- return created;
422
- }
407
+ var factory = attachPairFactory(ctx);
408
+ var createPairRecord = factory.createPairRecord;
409
+ var createWorkerForDriver = factory.createWorkerForDriver;
410
+ var createPair = factory.createPair;
411
+ var preflightWorkerForDriver = factory.preflightWorkerForDriver;
423
412
 
424
- function createPair(ws, msg) {
425
- try {
426
- var created = createPairRecord(ws, msg);
427
- ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: created.group });
428
- } catch (e) {
429
- ctx.sendTo(ws, { type: "pair_session_created", ok: false, error: e.message || String(e) });
430
- }
431
- }
413
+ lifecycle = attachPairLifecycle({
414
+ sm: sm,
415
+ splitStore: store,
416
+ createWorkerForDriver: function (driver, args) { return createWorkerForDriver(driver, args); },
417
+ preflightWorkerForDriver: function (driver, args) { return preflightWorkerForDriver(driver, args); },
418
+ sendToPartner: function (args, caller) { return sendToPartner(args, caller); },
419
+ finishDelegation: finishDelegation,
420
+ cancelWorkerPermissions: function (worker, reason) {
421
+ return workerPermission.cancelForSession(worker, reason);
422
+ },
423
+ turnControl: turnControl,
424
+ });
425
+
426
+ workerPermission = attachWorkerPermission({
427
+ sm: sm,
428
+ splitStore: store,
429
+ onProcessingChanged: ctx.onProcessingChanged,
430
+ resumeDriverWithMessage: resumeDriverWithMessage,
431
+ requestDetach: requestDetach,
432
+ });
432
433
 
433
434
  workerProposal = attachWorkerProposal({
434
435
  sm: sm,
@@ -440,8 +441,11 @@ function attachSessionPair(ctx) {
440
441
  getLinuxUserForSession: ctx.getLinuxUserForSession,
441
442
  onProcessingChanged: ctx.onProcessingChanged,
442
443
  adapters: ctx.adapters,
444
+ dangerouslySkipPermissions: ctx.dangerouslySkipPermissions,
443
445
  createPairRecord: createPairRecord,
446
+ recordGenerationStart: function (driver, worker) { return lifecycle.recordGenerationStart(driver, worker); },
444
447
  sendToPartner: sendToPartner,
448
+ replacePartner: function (args, session) { return lifecycle.replacePartner(args, session); },
445
449
  });
446
450
 
447
451
  function handleMessage(ws, msg) {
@@ -468,18 +472,27 @@ function attachSessionPair(ctx) {
468
472
  var group = store.groupForMember(session.localId);
469
473
  var pairPrompt = "";
470
474
  if (group && group.pair && group.pair.driverId === session.localId) {
471
- pairPrompt = "You are the Driver in a two-agent pair. The tools send_to_partner, read_partner, interrupt_partner, and close_partner are provided directly to you. Infer the user's intended target from conversational and UI context. References to the user-visible paired or split pane, its session, its activity or status, or a collaborator the user opened resolve to Clay's Split Worker and the appropriate partner tool. Internal Sub-agents are a distinct execution mechanism, not a lexical category; use them only when the user clearly intends internal or background parallel delegation rather than the visible paired session. When ambiguous and a visible pair exists, prefer the visible Split Worker. If the ambiguity would materially change where work runs, ask a concise clarification instead of guessing. Use send_to_partner to delegate concrete bounded work to the Split Worker. The human may also message or stop the Split Worker directly; treat that intervention as authoritative. Use close_partner immediately when the human asks to close, dismiss, or remove the Split Worker pane; it preserves the session history. A completed Split Worker turn leaves the Split Worker session available for more work. Use interrupt_partner when execution must stop or change direction but the pair should remain open; its work is partial and unverified until you review it. If review or user feedback requires corrections to the Split Worker's implementation, delegate a follow-up turn to that Split Worker instead of editing the Split Worker-owned files yourself. If the Split Worker is no longer available, keep the work in the visible Split Worker flow and use send_to_partner again after the stale pair is removed; never substitute a background Sub-agent. If a non-waiting delegation finishes later, Clay pushes the result back and resumes you automatically; use read_partner only when you need an interim status check. Integrate and verify the final outcome. Do not search the project for implementations of these tools, and do not delegate work that must be performed sequentially in this same session.";
472
- } else if (!group && !ctx.isMate) {
473
- pairPrompt = "Infer the user's intended target from conversational and UI context. References to a user-visible paired or split pane, its session, its activity or status, or a collaborator the user wants opened resolve to Clay's Split Worker and its partner tools. Internal Sub-agents are a distinct execution mechanism, not a lexical category; use them only when the user clearly intends internal or background parallel delegation rather than a visible paired session. When ambiguity would materially change where work runs, ask a concise clarification instead of guessing. When the user intends a visible collaborator, call send_to_partner directly; Clay will create the paired Split Worker session and open it in the right pane automatically before delivering the task. Do not ask the user to start split mode and do not use a background Sub-agent as a substitute for a visible Split Worker.";
475
+ pairPrompt = pairPrompts.DRIVER_CORE;
476
+ if (driverOrchestration.isHighTierDriverSession(session, sm)) {
477
+ pairPrompt += " " + pairPrompts.DRIVER_DELEGATION;
478
+ }
479
+ } else if (!group && !ctx.isMate && driverEligibility.isEligibleDriverSession(session, sm) &&
480
+ driverOrchestration.isHighTierDriverSession(session, sm)) {
481
+ pairPrompt = pairPrompts.UNPAIRED + " " + workerProposal.getSystemPrompt(session);
474
482
  }
475
- return [pairPrompt, workerProposal.getSystemPrompt(session)].filter(Boolean).join("\n\n");
483
+ return pairPrompt;
476
484
  }
477
485
 
478
- return { getToolDefs: getToolDefs, handleMessage: handleMessage, handleTurnDone: handleTurnDone, getSystemPrompt: getSystemPrompt };
486
+ return {
487
+ beginHumanTurn: turnControl.beginHumanTurn,
488
+ getToolDefs: getToolDefs,
489
+ handleHumanStop: handleHumanStop,
490
+ handleMessage: handleMessage,
491
+ handleTurnDone: handleTurnDone,
492
+ getSystemPrompt: getSystemPrompt,
493
+ respondToWorkerProposal: workerProposal.respondToProposal,
494
+ workerPermission: workerPermission,
495
+ };
479
496
  }
480
497
 
481
- module.exports = {
482
- attachSessionPair: attachSessionPair,
483
- responseText: responseText,
484
- recentTurns: recentTurns,
485
- };
498
+ module.exports = { attachSessionPair: attachSessionPair, responseText: responseText, recentTurns: recentTurns };
@@ -198,7 +198,7 @@ function attachSessionSpawn(ctx) {
198
198
  var create = typeof sm.createSessionRaw === "function" ? sm.createSessionRaw : sm.createSession;
199
199
  var session = create.call(sm, {
200
200
  ownerId: parent.ownerId || null,
201
- sessionVisibility: parent.sessionVisibility || "shared",
201
+ sessionVisibility: "private",
202
202
  vendor: vendor,
203
203
  });
204
204
  session.spawn = { parentId: parent.localId, index: index, batchId: batchId };
@@ -3,7 +3,9 @@ var path = require("path");
3
3
  var crypto = require("crypto");
4
4
  var { execFileSync } = require("child_process");
5
5
  var { CODEX_DEFAULTS, getCodexConfig } = require("./codex-defaults");
6
+ var updateSnooze = require("./update-snooze");
6
7
  var yoke = require("./yoke");
8
+ var sessionVisibility = require("./session-visibility");
7
9
 
8
10
  function vendorSupportsTui(vendor) {
9
11
  var info = yoke.getVendorInfo(vendor);
@@ -71,6 +73,9 @@ function attachSessions(ctx) {
71
73
  var send = ctx.send;
72
74
  var sendTo = ctx.sendTo;
73
75
  var sendToAdmins = ctx.sendToAdmins;
76
+ var broadcastUpdateAvailable = ctx.broadcastUpdateAvailable || function () {};
77
+ var broadcastUpdateStarted = ctx.broadcastUpdateStarted || function () {};
78
+ var sendToUpdateUser = ctx.sendToUpdateUser || function () {};
74
79
  var sendToSession = ctx.sendToSession;
75
80
  var sendToSessionOthers = ctx.sendToSessionOthers;
76
81
  var opts = ctx.opts;
@@ -492,7 +497,7 @@ function attachSessions(ctx) {
492
497
  sm.sweepBlankSessions();
493
498
  var sessionOpts = {};
494
499
  if (ws._clayUser && usersModule.isMultiUser()) sessionOpts.ownerId = ws._clayUser.id;
495
- if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility;
500
+ sessionOpts.sessionVisibility = sessionVisibility.normalize(msg.sessionVisibility);
496
501
  if (msg.vendor) sessionOpts.vendor = msg.vendor;
497
502
  // Mode resolution: vendors without a TUI session mode are always GUI.
498
503
  // TUI-capable sessions honor the explicit msg.mode if provided, otherwise
@@ -577,7 +582,8 @@ function attachSessions(ctx) {
577
582
  if (reusable) {
578
583
  if (sessionOpts.vendor && reusable.vendor !== sessionOpts.vendor) reusable.vendor = sessionOpts.vendor;
579
584
  if (!reusable.effort) reusable.effort = sessionOpts.effort;
580
- if (msg.sessionVisibility) reusable.sessionVisibility = msg.sessionVisibility;
585
+ sm.setSessionVisibility(reusable.localId, sessionOpts.sessionVisibility);
586
+ sessionVisibility.revokeViewers(ctx, reusable);
581
587
  sm.switchSession(reusable.localId, ws);
582
588
  newSess = reusable;
583
589
  } else {
@@ -605,9 +611,7 @@ function attachSessions(ctx) {
605
611
  }
606
612
 
607
613
  if (msg.type === "set_session_visibility") {
608
- if (typeof msg.sessionId === "number" && (msg.visibility === "shared" || msg.visibility === "private")) {
609
- sm.setSessionVisibility(msg.sessionId, msg.visibility);
610
- }
614
+ sessionVisibility.handleChange(ctx, ws, msg);
611
615
  return true;
612
616
  }
613
617
 
@@ -897,25 +901,30 @@ function attachSessions(ctx) {
897
901
  }
898
902
 
899
903
  if (msg.type === "set_update_channel") {
900
- if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return true;
904
+ if (!updateSnooze.isUpdateAdmin(ws, usersModule)) return true;
901
905
  var newChannel = msg.channel === "beta" ? "beta" : "stable";
902
906
  setUpdateChannel(newChannel);
903
907
  setLatestVersion(null);
904
908
  if (typeof opts.onSetUpdateChannel === "function") {
905
909
  opts.onSetUpdateChannel(newChannel);
906
910
  }
907
- // Re-fetch with new channel and broadcast to admin clients
911
+ // Re-fetch with new channel and broadcast to admin clients, honouring
912
+ // any snooze each of them has on the version that comes back.
908
913
  fetchVersion(newChannel).then(function (v) {
909
914
  if (v && isNewer(v, currentVersion)) {
910
915
  setLatestVersion(v);
911
- sendToAdmins({ type: "update_available", version: v });
916
+ broadcastUpdateAvailable(v);
912
917
  }
913
918
  }).catch(function () {});
914
919
  return true;
915
920
  }
916
921
 
917
922
  if (msg.type === "check_update") {
918
- if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return true;
923
+ if (!updateSnooze.isUpdateAdmin(ws, usersModule)) return true;
924
+ // A manual check is the user deliberately re-engaging, so it answers
925
+ // truthfully and ends any snooze rather than reporting an update it
926
+ // would then refuse to show.
927
+ updateSnooze.clearSnooze(updateSnooze.userKeyFor(ws, usersModule));
919
928
  var updateChannel = getUpdateChannel();
920
929
  fetchVersion(updateChannel).then(function (v) {
921
930
  if (v && isNewer(v, currentVersion)) {
@@ -928,9 +937,40 @@ function attachSessions(ctx) {
928
937
  return true;
929
938
  }
930
939
 
940
+ if (msg.type === "update_snooze") {
941
+ if (!updateSnooze.isUpdateAdmin(ws, usersModule)) return true;
942
+ var snoozeKey = updateSnooze.userKeyFor(ws, usersModule);
943
+ // The target is always the server's own latest version. A version named
944
+ // in the payload is ignored, as are any timestamps or user ids.
945
+ var snoozeResult = updateSnooze.snooze(
946
+ snoozeKey,
947
+ getLatestVersion(),
948
+ msg.duration,
949
+ Date.now(),
950
+ msg.tzOffsetMinutes
951
+ );
952
+ if (!snoozeResult.ok) {
953
+ sendTo(ws, { type: "update_snoozed", ok: false, error: snoozeResult.error });
954
+ return true;
955
+ }
956
+ // Every live connection of this user, so a second device stops showing
957
+ // a banner the user has already dealt with.
958
+ sendToUpdateUser(snoozeKey, {
959
+ type: "update_snoozed",
960
+ ok: true,
961
+ version: snoozeResult.version,
962
+ until: snoozeResult.until,
963
+ duration: snoozeResult.duration,
964
+ });
965
+ return true;
966
+ }
967
+
931
968
  if (msg.type === "update_now") {
932
- if (usersModule.isMultiUser() && (!ws._clayUser || ws._clayUser.role !== "admin")) return true;
933
- send({ type: "update_started", version: getLatestVersion() || "" });
969
+ if (!updateSnooze.isUpdateAdmin(ws, usersModule)) return true;
970
+ // Installing makes any snooze moot; drop it so the record does not
971
+ // outlive the version it referred to.
972
+ updateSnooze.clearSnooze(updateSnooze.userKeyFor(ws, usersModule));
973
+ broadcastUpdateStarted(getLatestVersion());
934
974
  var _ipc = require("./ipc");
935
975
  var _config = require("./config");
936
976
  _ipc.sendIPCCommand(_config.socketPath(), { cmd: "update" });
@@ -964,7 +1004,8 @@ function attachSessions(ctx) {
964
1004
 
965
1005
  if (msg.type === "stop") {
966
1006
  var session = getSessionForWs(ws);
967
- if (session && session.isProcessing) {
1007
+ if (session && (session.isProcessing || session._queryStarting)) {
1008
+ if (typeof ctx.onHumanPairStop === "function") ctx.onHumanPairStop(session);
968
1009
  session.taskStopRequested = true;
969
1010
  if (session.abortController) session.abortController.abort();
970
1011
  }
@@ -1736,8 +1777,12 @@ function attachSessions(ctx) {
1736
1777
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid branch name" });
1737
1778
  return true;
1738
1779
  }
1780
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(wtDirName)) {
1781
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Invalid worktree directory name" });
1782
+ return true;
1783
+ }
1739
1784
  if (typeof onCreateWorktree === "function") {
1740
- var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase);
1785
+ var wtResult = onCreateWorktree(slug, wtBranch, wtDirName, wtBase, ws._clayUser);
1741
1786
  sendTo(ws, { type: "create_worktree_result", ok: wtResult.ok, slug: wtResult.slug, error: wtResult.error });
1742
1787
  } else {
1743
1788
  sendTo(ws, { type: "create_worktree_result", ok: false, error: "Not supported" });