clay-server 4.3.0-beta.4 → 4.3.0-beta.6

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 (54) hide show
  1. package/lib/driver-continuation-pair.js +5 -1
  2. package/lib/multi-worker-feature.js +11 -0
  3. package/lib/project-connection.js +2 -0
  4. package/lib/project-pair-autonomous-stop.js +14 -8
  5. package/lib/project-pair-close-transaction.js +59 -0
  6. package/lib/project-pair-global-stop.js +18 -0
  7. package/lib/project-pair-lifecycle-status.js +34 -0
  8. package/lib/project-pair-lifecycle.js +58 -76
  9. package/lib/project-pair-message.js +1 -1
  10. package/lib/project-pair-owned-stop.js +7 -4
  11. package/lib/project-pair-result-capture.js +116 -0
  12. package/lib/project-pair-result-delivery.js +140 -0
  13. package/lib/project-pair-result-outbox.js +400 -0
  14. package/lib/project-pair-result-pipeline.js +16 -0
  15. package/lib/project-pair-result-recovery.js +229 -0
  16. package/lib/project-pair-structural-close.js +20 -0
  17. package/lib/project-pair-task-control.js +45 -18
  18. package/lib/project-pair-worker-close.js +68 -0
  19. package/lib/project-session-pair-prompt.js +26 -0
  20. package/lib/project-session-pair.js +87 -106
  21. package/lib/project-worker-creation-proposal.js +176 -0
  22. package/lib/project-worker-permission.js +7 -8
  23. package/lib/project-worker-proposal-runner.js +45 -0
  24. package/lib/project-worker-proposal.js +79 -115
  25. package/lib/project.js +38 -1
  26. package/lib/public/app.js +1 -0
  27. package/lib/public/css/messages.css +12 -0
  28. package/lib/public/css/pane.css +48 -2
  29. package/lib/public/modules/app-connection.js +49 -76
  30. package/lib/public/modules/app-messages.js +11 -0
  31. package/lib/public/modules/pair-result-status.js +87 -0
  32. package/lib/public/modules/project-activation.js +10 -7
  33. package/lib/public/modules/sidebar-sessions.js +31 -11
  34. package/lib/public/modules/split-group-helpers.js +74 -2
  35. package/lib/public/modules/split-pair-ui.js +28 -6
  36. package/lib/public/modules/split-pane-reconciler.js +28 -0
  37. package/lib/public/modules/split-pane-renderer.js +107 -0
  38. package/lib/public/modules/split-view.js +58 -188
  39. package/lib/public/modules/websocket-watchdog.js +126 -0
  40. package/lib/public/modules/worker-pane-lock.js +13 -3
  41. package/lib/server.js +1 -0
  42. package/lib/session-pair-factory.js +66 -0
  43. package/lib/session-pair-mcp-server.js +10 -5
  44. package/lib/session-pair-prompts.js +41 -25
  45. package/lib/session-pair-target.js +42 -0
  46. package/lib/session-pair-turn-control.js +11 -6
  47. package/lib/session-split-group-anchors.js +89 -7
  48. package/lib/session-split-group-persistence.js +136 -0
  49. package/lib/session-split-group-roles.js +164 -0
  50. package/lib/session-split-group-v2-store.js +146 -0
  51. package/lib/session-split-groups.js +109 -99
  52. package/lib/sessions.js +33 -2
  53. package/lib/ws-schema.js +2 -0
  54. package/package.json +1 -1
@@ -0,0 +1,45 @@
1
+ function attachProposalRunner(ctx) {
2
+ function resumeDriver(session, text) {
3
+ var sdk = ctx.getSdk();
4
+ if (!sdk) return Promise.reject(new Error("SDK bridge is not ready"));
5
+ ctx.sm.sendAndRecord(session, { type: "user_message", text: text, _internal: true });
6
+ session.sentToolResults = {};
7
+ if (!session.isProcessing) {
8
+ session.isProcessing = true;
9
+ ctx.onProcessingChanged();
10
+ ctx.sm.sendToSession(session, { type: "status", status: "processing" });
11
+ }
12
+ if (sdk.pushMessage(session, text)) return Promise.resolve();
13
+ return Promise.resolve(sdk.startQuery(session, text, undefined, ctx.getLinuxUserForSession(session)));
14
+ }
15
+
16
+ function parseResult(result) {
17
+ if (!result || result.isError || !result.content || !result.content[0]) {
18
+ return { status: "error", error: "Split Worker execution failed." };
19
+ }
20
+ try { return JSON.parse(result.content[0].text); }
21
+ catch (error) { return { status: "error", error: result.content[0].text || "Split Worker execution failed." }; }
22
+ }
23
+
24
+ async function runWorker(session, proposal) {
25
+ var result = parseResult(await ctx.sendToPartner({ message: proposal.message, wait: true,
26
+ timeoutSeconds: 900, workerId: proposal.workerId }, session));
27
+ ctx.updateProposal(session, proposal, { status: result.status === "complete" ? "completed" : result.status || "error",
28
+ resultPreview: result.response ? result.response.slice(0, 1200) : "", error: result.error || null });
29
+ var followup;
30
+ if (result.status === "complete") {
31
+ followup = "[Split Worker execution completed]\nReview and verify the Split Worker's result. The Split Worker session remains available: if the implementation needs corrections or additional edits, send a follow-up with send_to_partner instead of taking over the Split Worker-owned files yourself. If that 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.\n\n" + (result.response || "The Split Worker completed without a text summary.");
32
+ } else if (result.status === "interrupted") {
33
+ followup = "[Split Worker execution interrupted]\nThe Split Worker stopped mid-turn. Its work is PARTIAL and unverified — do not treat it as finished. Check partner_status before deciding next steps; this result alone does not identify who interrupted it. If the human stopped it, do not retry until a new human Driver message. Otherwise continue within the user's authorized task.";
34
+ } else if (result.status === "running") {
35
+ followup = "[Split Worker execution is still running]\nUse read_partner to inspect progress before completing the task.";
36
+ } else {
37
+ followup = "[Split Worker execution failed]\nThe existing Split Worker may still be reusable. Inspect partner_status and send a narrower follow-up with send_to_partner when the Worker and pair still exist; do not replace it merely because its task or transport failed. Retry replacement only when the replacement transaction reports that no new Worker was created and the failure is explicitly safe to retry.\n\n" + (result.error || result.response || "Unknown Split Worker error.");
38
+ }
39
+ await resumeDriver(session, followup);
40
+ }
41
+
42
+ return { resumeDriver: resumeDriver, runWorker: runWorker };
43
+ }
44
+
45
+ module.exports = { attachProposalRunner: attachProposalRunner };
@@ -3,8 +3,12 @@ var yoke = require("./yoke");
3
3
  var buildShape = require("./session-spawn-mcp-server").buildShape;
4
4
  var driverEligibility = require("./session-driver-eligibility");
5
5
  var driverOrchestration = require("./session-driver-orchestration");
6
+ var pairTarget = require("./session-pair-target");
7
+ var splitRoles = require("./session-split-group-roles");
6
8
  var proposalControl = require("./worker-proposal-control");
7
9
  var runtimeCatalog = require("./worker-runtime-catalog");
10
+ var proposalRunner = require("./project-worker-proposal-runner");
11
+ var creationProposalModule = require("./project-worker-creation-proposal");
8
12
  var MAX_SUMMARY_CHARS = 600, MAX_PLAN_CHARS = 6000, MAX_TASK_CHARS = 30000, MAX_RATIONALE_CHARS = 1000;
9
13
  var EVALUATION_OUTCOMES = ["succeeded", "partial", "failed", "abandoned"];
10
14
  function normalizeEvaluation(raw) {
@@ -161,66 +165,18 @@ function attachWorkerProposal(ctx) {
161
165
  proposalId: proposal.proposalId,
162
166
  }, patch));
163
167
  }
164
- async function propose(args, session) {
165
- if (!isEligible(session)) return toolResult({ error: "Split Worker proposals are only available in an eligible unpaired Driver session." });
166
- var superseded = proposalControl.pendingProposal(session);
167
- var supersedeId = typeof args.supersedeProposalId === "string" ? args.supersedeProposalId.trim() : "";
168
- if (superseded && superseded.proposalId !== supersedeId) return toolResult({ error: "A Split Worker suggestion is already awaiting a decision. Inspect it or supply its exact id to supersede it." });
169
- if (!superseded && supersedeId) return toolResult({ error: "The Split Worker suggestion to supersede is no longer pending." });
170
- if (superseded && superseded.status !== "pending") return toolResult({ error: "The Split Worker suggestion is already starting and cannot be superseded." });
171
- var summary = typeof args.summary === "string" ? args.summary.trim() : "";
172
- var plan = typeof args.plan === "string" ? args.plan.trim() : "";
173
- var task = typeof args.message === "string" ? args.message.trim() : "";
174
- var rationale = typeof args.recommendationRationale === "string" ? args.recommendationRationale.trim() : "";
175
- if (!summary || !plan || !task || !rationale) {
176
- return toolResult({ error: "summary, plan, message, and recommendationRationale are required." });
177
- }
178
- if (task.length > MAX_TASK_CHARS) return toolResult({ error: "The Split Worker task is too long." });
179
- await ensureModelCatalogs();
180
- if (!isEligible(session)) {
181
- return toolResult({ error: "The Driver session changed while Split Worker runtimes were loading." });
182
- }
183
- var currentPending = proposalControl.pendingProposal(session);
184
- if (currentPending !== superseded) return toolResult({ error: "The pending Split Worker suggestion changed while runtimes were loading." });
185
- var options = proposalOptions();
186
- if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Split Worker session." });
187
- var recommendation = chooseRecommendation(args, session, options);
188
- var proposal = {
189
- type: "worker_proposal",
190
- proposalId: "worker_" + crypto.randomUUID(),
191
- summary: summary.slice(0, MAX_SUMMARY_CHARS),
192
- plan: plan.slice(0, MAX_PLAN_CHARS),
193
- message: task,
194
- status: "pending",
195
- createdAt: Date.now(),
196
- recommendedVendor: recommendation.vendor,
197
- recommendedModel: recommendation.model,
198
- recommendedEffort: recommendation.effort,
199
- recommendationRationale: rationale.slice(0, MAX_RATIONALE_CHARS),
200
- options: options,
201
- };
202
- if (superseded) updateProposal(session, superseded, { status: "superseded", supersededBy: proposal.proposalId });
203
- sm.sendAndRecord(session, proposal);
204
- if (skipPermissionsEnabled(session) && recommendationCanAutoAccept(args, recommendation, options)) {
205
- var accepted = await acceptProposal(session, proposal, {
206
- vendor: recommendation.vendor,
207
- model: recommendation.model,
208
- effort: recommendation.effort,
209
- }, autoAcceptanceWs(session), true);
210
- return toolResult({
211
- status: accepted.ok ? "auto_accepted" : "posted",
212
- proposalId: proposal.proposalId,
213
- instruction: accepted.ok
214
- ? "The recorded Split Worker configuration was auto-accepted under the Driver session's full-access mode. End this turn now while the exact paired Worker runs."
215
- : "The automatic decision failed closed. The configuration card remains pending for the user; end this turn and wait for their decision.",
216
- });
217
- }
218
- return toolResult({
219
- status: "posted",
220
- proposalId: proposal.proposalId,
221
- instruction: "The Split Worker suggestion is visible in the chat. End this turn now and wait for the user's decision.",
222
- });
223
- }
168
+ var runner = proposalRunner.attachProposalRunner(Object.assign({}, ctx, { updateProposal: updateProposal }));
169
+ var resumeDriver = runner.resumeDriver;
170
+ var runWorker = runner.runWorker;
171
+ var creationProposal = creationProposalModule.attachWorkerCreationProposal({
172
+ sm: sm, splitStore: store, multiWorkerFeature: ctx.multiWorkerFeature,
173
+ canOffer: canOffer, isEligible: isEligible, ensureModelCatalogs: ensureModelCatalogs,
174
+ proposalOptions: proposalOptions, chooseRecommendation: chooseRecommendation,
175
+ skipPermissionsEnabled: skipPermissionsEnabled, recommendationCanAutoAccept: recommendationCanAutoAccept,
176
+ autoAcceptanceWs: autoAcceptanceWs, acceptProposal: acceptProposal, updateProposal: updateProposal,
177
+ toolResult: toolResult, maxSummaryChars: MAX_SUMMARY_CHARS, maxPlanChars: MAX_PLAN_CHARS,
178
+ maxTaskChars: MAX_TASK_CHARS, maxRationaleChars: MAX_RATIONALE_CHARS,
179
+ });
224
180
  async function proposeReplacement(args, session) {
225
181
  var group = session && store.groupForMember(session.localId);
226
182
  if (ctx.isMate || !isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) || !group ||
@@ -228,7 +184,17 @@ function attachWorkerProposal(ctx) {
228
184
  return toolResult({ error: "Split Worker replacement proposals require the exact paired Driver session." });
229
185
  }
230
186
  var sourceGroupId = group.id;
231
- var sourceWorkerId = group.pair.workerId;
187
+ var addressed;
188
+ try { addressed = pairTarget.resolveGroupTarget(sm, group, session, args, {
189
+ mutation: true, multiWorkerFeature: ctx.multiWorkerFeature,
190
+ }); }
191
+ catch (e) { return toolResult({ error: e.message || String(e) }); }
192
+ var sourceWorkerId = addressed.workerId;
193
+ var sourceWorkerOriginId = addressed.partner.sessionOriginId || null;
194
+ var sourceGeneration = addressed.partner._pairGeneration ||
195
+ addressed.partner.sessionProvenance && addressed.partner.sessionProvenance.generation || null;
196
+ var sourceMembership = creationProposalModule.captureMembership(sm, session, group);
197
+ if (!sourceMembership) return toolResult({ error: "The exact Driver/Split Worker identities are not durable enough to replace a Worker." });
232
198
  var superseded = proposalControl.pendingProposal(session);
233
199
  var supersedeId = typeof args.supersedeProposalId === "string" ? args.supersedeProposalId.trim() : "";
234
200
  if (superseded && superseded.proposalId !== supersedeId) return toolResult({ error: "A Split Worker proposal is already awaiting a decision. Inspect it or supply its exact id to supersede it." });
@@ -245,9 +211,17 @@ function attachWorkerProposal(ctx) {
245
211
  var liveGroup = store.groupForMember(session.localId);
246
212
  if (!isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) ||
247
213
  !liveGroup || liveGroup.id !== sourceGroupId || !liveGroup.pair ||
248
- liveGroup.pair.driverId !== session.localId || liveGroup.pair.workerId !== sourceWorkerId) {
214
+ liveGroup.pair.driverId !== session.localId || liveGroup.members.indexOf(sourceWorkerId) === -1 ||
215
+ sm.sessions.get(sourceWorkerId) !== addressed.partner ||
216
+ (addressed.partner.sessionOriginId || null) !== sourceWorkerOriginId ||
217
+ (addressed.partner._pairGeneration || addressed.partner.sessionProvenance && addressed.partner.sessionProvenance.generation || null) !== sourceGeneration ||
218
+ !creationProposalModule.matchesMembership(sm, session, liveGroup, sourceMembership)) {
249
219
  return toolResult({ error: "The Driver/Split Worker pair changed while replacement runtimes were loading." });
250
220
  }
221
+ try { pairTarget.resolveGroupTarget(sm, liveGroup, session, args, {
222
+ mutation: true, multiWorkerFeature: ctx.multiWorkerFeature,
223
+ }); }
224
+ catch (e) { return toolResult({ error: e.message || String(e) }); }
251
225
  var currentPending = proposalControl.pendingProposal(session);
252
226
  if (currentPending !== superseded) return toolResult({ error: "The pending Split Worker proposal changed while runtimes were loading." });
253
227
  var options = proposalOptions();
@@ -274,6 +248,10 @@ function attachWorkerProposal(ctx) {
274
248
  options: options,
275
249
  sourceGroupId: sourceGroupId,
276
250
  sourceWorkerId: sourceWorkerId,
251
+ sourceWorkerOriginId: sourceWorkerOriginId,
252
+ sourceGeneration: sourceGeneration,
253
+ sourceMembership: sourceMembership,
254
+ workerId: sourceWorkerId,
277
255
  interrupt: args.interrupt === true,
278
256
  evaluation: normalizedEvaluation ? normalizedEvaluation.value : null,
279
257
  transactionId: "replace_" + crypto.randomUUID(),
@@ -300,50 +278,6 @@ function attachWorkerProposal(ctx) {
300
278
  instruction: "The replacement configuration is visible in the chat. End this turn now and wait for the user's decision.",
301
279
  });
302
280
  }
303
- function resumeDriver(session, text) {
304
- var sdk = ctx.getSdk();
305
- if (!sdk) return Promise.reject(new Error("SDK bridge is not ready"));
306
- var message = { type: "user_message", text: text, _internal: true };
307
- sm.sendAndRecord(session, message);
308
- session.sentToolResults = {};
309
- if (!session.isProcessing) {
310
- session.isProcessing = true;
311
- ctx.onProcessingChanged();
312
- sm.sendToSession(session, { type: "status", status: "processing" });
313
- }
314
- if (sdk.pushMessage(session, text)) return Promise.resolve();
315
- return Promise.resolve(sdk.startQuery(session, text, undefined, ctx.getLinuxUserForSession(session)));
316
- }
317
-
318
- function parsePartnerResult(result) {
319
- if (!result || result.isError || !result.content || !result.content[0]) {
320
- return { status: "error", error: "Split Worker execution failed." };
321
- }
322
- try { return JSON.parse(result.content[0].text); }
323
- catch (e) { return { status: "error", error: result.content[0].text || "Split Worker execution failed." }; }
324
- }
325
-
326
- async function runWorker(session, proposal) {
327
- var result = parsePartnerResult(await ctx.sendToPartner({ message: proposal.message, wait: true, timeoutSeconds: 900 }, session));
328
- var status = result.status === "complete" ? "completed" : result.status;
329
- updateProposal(session, proposal, {
330
- status: status || "error",
331
- resultPreview: result.response ? result.response.slice(0, 1200) : "",
332
- error: result.error || null,
333
- });
334
- var followup;
335
- if (result.status === "complete") {
336
- followup = "[Split Worker execution completed]\nReview and verify the Split Worker's result. The Split Worker session remains available: if the implementation needs corrections or additional edits, send a follow-up with send_to_partner instead of taking over the Split Worker-owned files yourself. If that 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.\n\n" + (result.response || "The Split Worker completed without a text summary.");
337
- } else if (result.status === "interrupted") {
338
- followup = "[Split Worker execution interrupted]\nThe Split Worker stopped mid-turn. Its work is PARTIAL and unverified — do not treat it as finished. Check partner_status before deciding next steps; this result alone does not identify who interrupted it. If the human stopped it, do not retry until a new human Driver message. Otherwise continue within the user's authorized task.";
339
- } else if (result.status === "running") {
340
- followup = "[Split Worker execution is still running]\nUse read_partner to inspect progress before completing the task.";
341
- } else {
342
- followup = "[Split Worker execution failed]\nThe existing Split Worker may still be reusable. Inspect partner_status and send a narrower follow-up with send_to_partner when the Worker and pair still exist; do not replace it merely because its task or transport failed. Retry replacement only when the replacement transaction reports that no new Worker was created and the failure is explicitly safe to retry.\n\n" + (result.error || result.response || "Unknown Split Worker error.");
343
- }
344
- await resumeDriver(session, followup);
345
- }
346
-
347
281
  function sessionForResponse(ws) {
348
282
  var session = sm.sessions.get(ws._clayActiveSession);
349
283
  if (!session) throw new Error("active Driver session not found");
@@ -356,19 +290,28 @@ function attachWorkerProposal(ctx) {
356
290
 
357
291
  async function acceptProposal(session, proposal, msg, ws, trustedAutoAcceptance) {
358
292
  if (!isLiveSession(session)) throw new Error("The Driver session is no longer live");
359
- if (proposal.action !== "replace" && !isEligible(session)) {
293
+ if (!proposal.action && !isEligible(session)) {
360
294
  throw new Error("The Driver is no longer eligible to create this Split Worker");
361
295
  }
362
296
  var approvedGroup = null;
363
- if (proposal.action === "replace") {
297
+ if (proposal.action === "add") {
298
+ var addition = creationProposal.validateAcceptance(session, proposal);
299
+ if (!addition.ok) throw new Error("The Driver/Split Worker membership changed before the addition was approved");
300
+ approvedGroup = addition.group;
301
+ } else if (proposal.action === "replace") {
364
302
  approvedGroup = store.groupForMember(session.localId);
303
+ var approvedRoles = splitRoles.normalizePair(approvedGroup && approvedGroup.pair, approvedGroup && approvedGroup.members);
304
+ var approvedWorker = sm.sessions.get(proposal.sourceWorkerId);
365
305
  if (!driverEligibility.isEligibleDriverSession(session, sm) || !approvedGroup ||
366
- approvedGroup.id !== proposal.sourceGroupId || !approvedGroup.pair ||
367
- approvedGroup.pair.driverId !== session.localId || approvedGroup.pair.workerId !== proposal.sourceWorkerId) {
306
+ approvedGroup.id !== proposal.sourceGroupId || !approvedRoles.ok || approvedRoles.kind === "adhoc" ||
307
+ approvedRoles.driverId !== session.localId || approvedRoles.workerIds.indexOf(proposal.sourceWorkerId) === -1 ||
308
+ !approvedWorker || (approvedWorker.sessionOriginId || null) !== (proposal.sourceWorkerOriginId || null) ||
309
+ (approvedWorker._pairGeneration || approvedWorker.sessionProvenance && approvedWorker.sessionProvenance.generation || null) !== proposal.sourceGeneration ||
310
+ !creationProposalModule.matchesMembership(sm, session, approvedGroup, proposal.sourceMembership)) {
368
311
  throw new Error("The Driver/Split Worker pair changed before the replacement was approved");
369
312
  }
370
313
  }
371
- var options = proposalOptions();
314
+ var options = proposal.options || proposalOptions();
372
315
  var vendor = msg.vendor || proposal.recommendedVendor;
373
316
  var model = msg.model || "";
374
317
  if (options.installedVendors.indexOf(vendor) === -1) throw new Error("Selected Split Worker vendor is not installed");
@@ -390,6 +333,19 @@ function attachWorkerProposal(ctx) {
390
333
  };
391
334
  updateProposal(session, proposal, startingPatch);
392
335
  try {
336
+ if (proposal.action === "add") {
337
+ var added = ctx.addWorkerForDriver(session, {
338
+ workerVendor: vendor, workerModel: model, workerEffort: effort,
339
+ expectedGroup: approvedGroup, expectedWorkerIds: proposal.sourceWorkerIds,
340
+ });
341
+ ctx.recordGenerationStart(session, added.worker);
342
+ updateProposal(session, proposal, { status: "running", groupId: added.group.id, workerId: added.worker.localId });
343
+ runWorker(session, proposal).catch(function (err) {
344
+ updateProposal(session, proposal, { status: "error", error: err.message || String(err) });
345
+ resumeDriver(session, "[Split Worker execution failed]\n" + (err.message || String(err))).catch(function () {});
346
+ });
347
+ return { ok: true, status: "running", group: added.group, addition: added };
348
+ }
393
349
  if (proposal.action === "replace") {
394
350
  var liveGroup = approvedGroup;
395
351
  var replaced = await ctx.replacePartner({
@@ -399,6 +355,7 @@ function attachWorkerProposal(ctx) {
399
355
  workerEffort: effort,
400
356
  evaluation: proposal.evaluation || undefined,
401
357
  transactionId: proposal.transactionId,
358
+ workerId: proposal.workerId,
402
359
  }, session);
403
360
  liveGroup = store.groupForMember(session.localId);
404
361
  updateProposal(session, proposal, { status: "running", groupId: liveGroup.id, workerId: replaced.workerSessionId });
@@ -423,9 +380,11 @@ function attachWorkerProposal(ctx) {
423
380
  } catch (err) {
424
381
  if (proposal.action === "replace") {
425
382
  var restoredGroup = store.groupForMember(session.localId);
426
- if (restoredGroup && restoredGroup.pair && restoredGroup.pair.driverId === session.localId &&
427
- restoredGroup.pair.workerId === proposal.sourceWorkerId) {
383
+ var restoredRoles = splitRoles.normalizePair(restoredGroup && restoredGroup.pair, restoredGroup && restoredGroup.members);
384
+ if (restoredGroup && restoredRoles.ok && restoredRoles.kind !== "adhoc" &&
385
+ restoredRoles.driverId === session.localId && restoredRoles.workerIds.indexOf(proposal.sourceWorkerId) !== -1) {
428
386
  proposal.sourceGroupId = restoredGroup.id;
387
+ proposal.sourceMembership = creationProposalModule.captureMembership(sm, session, restoredGroup);
429
388
  }
430
389
  proposal.transactionId = "replace_" + crypto.randomUUID();
431
390
  }
@@ -449,6 +408,8 @@ function attachWorkerProposal(ctx) {
449
408
  updateProposal(session, proposal, { status: "declined" });
450
409
  var declinedText = proposal.action === "replace"
451
410
  ? "[Split Worker replacement declined]\nContinue with the existing Split Worker and the current task."
411
+ : proposal.action === "add"
412
+ ? "[Additional Split Worker declined]\nContinue with the existing Split Worker and current plan."
452
413
  : "[Split Worker proposal declined]\nContinue this task in the current Driver session using the plan you already prepared.";
453
414
  await resumeDriver(session, declinedText);
454
415
  return { ok: true, status: "declined" };
@@ -468,7 +429,7 @@ function attachWorkerProposal(ctx) {
468
429
  var persistent = !!(options && options.persistent);
469
430
  var controlsOnly = !!(options && options.controlsOnly);
470
431
  if (!isEligible(session) && !(persistent && canOffer(session))) return [];
471
- var proposalTools = controlsOnly ? [] : [{
432
+ var proposalTools = controlsOnly && !creationProposal.canMountProposal(session) ? [] : [{
472
433
  name: "propose_worker",
473
434
  description: "Propose a visible Split Worker for implementation-heavy execution. This non-mutating tool only shows the user a card where they choose vendor, model, and effort. It never creates a session or delegates work until the user accepts.",
474
435
  inputSchema: buildShape({
@@ -481,7 +442,7 @@ function attachWorkerProposal(ctx) {
481
442
  recommendationRationale: { type: "string", description: "Concise Driver-authored explanation of why the recommended vendor, model, and effort fit this exact task." },
482
443
  supersedeProposalId: { type: "string", description: "Exact pending proposal id to resolve as superseded when posting this replacement suggestion." },
483
444
  }, ["summary", "plan", "message", "recommendationRationale"]),
484
- handler: function (args) { return propose(args || {}, session); },
445
+ handler: function (args) { return creationProposal.propose(args || {}, session); },
485
446
  }];
486
447
  return proposalTools.concat(proposalControl.getToolDefs({
487
448
  inspect: function () { return toolResult(isLiveSession(session) ? proposalControl.projection(proposalControl.pendingProposal(session)) : { status: "rejected", reason: "The Driver session is no longer live." }); },
@@ -501,6 +462,9 @@ function attachWorkerProposal(ctx) {
501
462
  }
502
463
 
503
464
  function getSystemPrompt(session) {
465
+ if (creationProposal.canMountProposal(session) && !isEligible(session)) {
466
+ return "This Driver may configure one additional Split Worker through propose_worker. With two Workers, pass workerId to every targeted partner, queue, interrupt, replacement, close, permission, and status operation. Give each Worker independent file ownership and bounded work. Do not assign simultaneous overlapping file edits unless the work is explicitly separated, such as by distinct paths or worktrees.";
467
+ }
504
468
  if (!isEligible(session) || !driverOrchestration.isHighTierDriverSession(session, sm)) return "";
505
469
  return "For implementation-heavy work, use propose_worker before substantial execution. Work is implementation-heavy when it spans multiple modules, crosses client/server/data boundaries, requires a migration, or contains independent investigation and execution. Do not skip delegation merely because you can implement it yourself; preserve the Driver for decomposition, runtime selection, judgment, review, and integration. The user's explicit request to work directly takes precedence, and work that must remain sequential in this session stays here. Recommend an exact available vendor, model, and effort, and give a concise recommendationRationale explaining why all three fit the task. Clay always records and shows the runtime configuration card. In full-access mode Clay may auto-accept that exact validated recommendation; otherwise it waits for the user's explicit choice. After posting, end the turn. If accepted, Clay creates the exact Driver/Split Worker pair and delegates the proposed task once. If declined, continue in this Driver session. Do not call send_to_partner while unpaired.";
506
470
  }
package/lib/project.js CHANGED
@@ -55,6 +55,8 @@ var { attachDelegatedFollowUp } = require("./project-delegated-follow-up");
55
55
  var { attachCapsuleTurn } = require("./project-capsule-turn");
56
56
  var workspaceSessionRef = require("./workspace-query-service").sessionRef;
57
57
  var { attachSessionPair } = require("./project-session-pair");
58
+ var { attachPairResultOutbox } = require("./project-pair-result-outbox");
59
+ var { attachPairResultRecovery } = require("./project-pair-result-recovery");
58
60
  var { attachSessionHandoff } = require("./project-session-handoff");
59
61
  var { attachDriverContinuation } = require("./project-driver-continuation");
60
62
  var driverContinuationAccess = require("./driver-continuation-access");
@@ -66,6 +68,7 @@ var { attachProjectLogs } = require("./project-logs");
66
68
  var { attachProjectLogFeedbackDelivery } = require("./project-log-feedback-delivery");
67
69
  var { attachProjectMateKnowledge } = require("./project-mate-knowledge");
68
70
  var { attachSplitGroups } = require("./session-split-groups");
71
+ var multiWorkerFeature = require("./multi-worker-feature");
69
72
  var toolControlMcp = require("./tool-control-mcp-server");
70
73
  var capsuleFloor = require("./capsule-display-floor");
71
74
  var toolLlm = require("./tool-llm");
@@ -205,6 +208,7 @@ function safeAbsPath(requested) {
205
208
  * opts: { cwd, slug, title, pushModule, debug, dangerouslySkipPermissions, currentVersion }
206
209
  */
207
210
  function createProjectContext(opts) {
211
+ var multiWorkerGate = multiWorkerFeature.fromServerConfig(opts);
208
212
  var cwd = opts.cwd;
209
213
  var slug = opts.slug;
210
214
  var project = path.basename(cwd);
@@ -659,6 +663,7 @@ function createProjectContext(opts) {
659
663
  clients: clients,
660
664
  sendTo: sendTo,
661
665
  usersModule: usersModule,
666
+ multiWorkerFeature: multiWorkerGate,
662
667
  onPairChanged: function (group) {
663
668
  if (!sdk || !group || !Array.isArray(group.members)) return;
664
669
  for (var i = 0; i < group.members.length; i++) {
@@ -819,10 +824,14 @@ function createProjectContext(opts) {
819
824
 
820
825
  // The SDK bridge is created after local MCP servers. Session spawning uses
821
826
  // a getter so tool handlers see the initialized bridge when they run.
827
+ var _pairResultOutbox = attachPairResultOutbox({ storageDir: sm.sessionsDir, projectSlug: slug });
828
+ _pairResultOutbox.recoverPending(sm.sessions);
829
+ var _pairResultRecovery = null;
822
830
  var _sessionPair = attachSessionPair({
823
831
  sm: sm,
824
832
  isMate: isMate,
825
833
  splitStore: _splitGroups.store,
834
+ multiWorkerFeature: multiWorkerGate,
826
835
  getSdk: function () { return sdk; },
827
836
  send: send,
828
837
  sendTo: sendTo,
@@ -837,12 +846,31 @@ function createProjectContext(opts) {
837
846
  usersModule: usersModule,
838
847
  getLinuxUserForSession: getLinuxUserForSession,
839
848
  onProcessingChanged: onProcessingChanged,
849
+ projectSlug: slug,
850
+ resultOutbox: _pairResultOutbox,
851
+ onResultStateChange: function (driver, record) { if (_pairResultRecovery) _pairResultRecovery.stateChanged(driver, record); },
840
852
  adapters: adapters,
841
853
  onPartnerResult: function (driver, worker, token) {
842
854
  if (_scheduledTaskExecution) _scheduledTaskExecution.onPartnerResult(driver, worker, token);
843
855
  if (_autonomousRun) _autonomousRun.onPartnerResult(driver, worker, token);
844
856
  },
845
857
  });
858
+ var _pairWorkerClose = require("./project-pair-worker-close").attachPairWorkerClose({
859
+ sm: sm,
860
+ splitStore: _splitGroups.store,
861
+ multiWorkerFeature: multiWorkerGate,
862
+ isMate: isMate,
863
+ projectSlug: slug,
864
+ hasSocket: function (ws) { return clients.has(ws); },
865
+ sendTo: sendTo,
866
+ closeWorker: _sessionPair.closeWorker,
867
+ });
868
+ _pairResultRecovery = attachPairResultRecovery({
869
+ sm: sm, store: _splitGroups.store, outbox: _pairResultOutbox, resultCapture: _sessionPair.resultCapture,
870
+ wake: _sessionPair.wakePartnerResult, projectSlug: slug, sendTo: sendTo,
871
+ blockedReason: _sessionPair.stopBarrier,
872
+ getClients: function () { return clients; }, isMultiUser: usersModule.isMultiUser,
873
+ });
846
874
  var _sessionSpawn = attachSessionSpawn({
847
875
  cwd: cwd,
848
876
  sm: sm,
@@ -1277,6 +1305,7 @@ function createProjectContext(opts) {
1277
1305
  },
1278
1306
  onProcessingChanged: onProcessingChanged,
1279
1307
  onSessionAvailable: function (session) {
1308
+ if (_pairResultRecovery) _pairResultRecovery.available(session);
1280
1309
  if (_scheduledMessages) _scheduledMessages.notify();
1281
1310
  if (_userMessage && session) setImmediate(function () { _userMessage.consumePendingMessage(session); });
1282
1311
  },
@@ -1741,6 +1770,8 @@ function createProjectContext(opts) {
1741
1770
  if (msg.type === "memory_delete") { _memory.handleMemoryDelete(ws, msg); return; }
1742
1771
 
1743
1772
  // --- Sessions, config, project mgmt (delegated to project-sessions.js) ---
1773
+ if (_pairResultRecovery.handleMessage(ws, msg)) return;
1774
+ if (_pairWorkerClose.handleMessage(ws, msg)) return;
1744
1775
  if (_sessionPair.handleMessage(ws, msg)) return;
1745
1776
  if (_sessionHandoff.handleMessage(ws, msg)) return;
1746
1777
  if (_driverContinuation.handleMessage(ws, msg)) return;
@@ -2016,6 +2047,7 @@ function createProjectContext(opts) {
2016
2047
  serverAuthToken: serverAuthToken,
2017
2048
  resolveDefaultAi: opts.resolveDefaultAi,
2018
2049
  });
2050
+ _pairResultRecovery.reconcile();
2019
2051
  var _toolLlmRuntime = attachProjectToolLlmRuntime({
2020
2052
  users: usersModule,
2021
2053
  osUsers: osUsers,
@@ -2066,7 +2098,11 @@ function createProjectContext(opts) {
2066
2098
  if (isMate || !_sessionPair.workerPermission) return false;
2067
2099
  return _sessionPair.workerPermission.isDriverOperated(session);
2068
2100
  },
2069
- beginHumanPairTurn: function (session) { return _sessionPair.beginHumanTurn(session); },
2101
+ beginHumanPairTurn: function (session) {
2102
+ var resumed = _sessionPair.beginHumanTurn(session);
2103
+ if (resumed) _pairResultRecovery.beginHumanTurn(session);
2104
+ return resumed;
2105
+ },
2070
2106
  authorizePendingDispatch: function (session, actor, item) {
2071
2107
  if (!session || sm.sessions.get(session.localId) !== session || !item || item.projectSlug !== slug) return false;
2072
2108
  if (_scheduledMessages && !_scheduledMessages.authorizePending(item, session)) return false;
@@ -2499,6 +2535,7 @@ function createProjectContext(opts) {
2499
2535
  _email: _email,
2500
2536
  restoreDebateState: restoreDebateState,
2501
2537
  scheduledMessages: _scheduledMessages,
2538
+ pairResultRecovery: _pairResultRecovery,
2502
2539
  sendCursorSharingState: opts.sendCursorSharingState,
2503
2540
  stopFileWatch: stopFileWatch,
2504
2541
  stopAllDirWatches: stopAllDirWatches,
package/lib/public/app.js CHANGED
@@ -276,6 +276,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
276
276
  splitPanes: null,
277
277
  splitGroups: [],
278
278
  splitDelegations: {},
279
+ pairResultStatus: [],
279
280
  notesTemporarilyHidden: false,
280
281
  dmMode: false,
281
282
  historyFrom: 0,
@@ -1387,6 +1387,18 @@ pre.mermaid-error {
1387
1387
  .plan-banner.plan-exit { color: var(--success); }
1388
1388
  .plan-banner.plan-exit .plan-banner-icon { color: var(--success); }
1389
1389
 
1390
+ .pair-result-status { display: grid; gap: 8px; padding: 10px 16px 0; }
1391
+ .pair-result-status-card { border: 1px solid var(--border); border-radius: 10px; background: var(--bg-alt); padding: 10px 12px; color: var(--text-secondary); font-size: 12px; }
1392
+ .pair-result-status-card > strong, .pair-result-status-card > span { display: block; }
1393
+ .pair-result-status-card > strong { color: var(--text); margin-bottom: 3px; }
1394
+ .pair-result-status-card details { margin-top: 7px; }
1395
+ .pair-result-status-card summary { cursor: pointer; color: var(--accent); }
1396
+ .pair-result-status-card pre { max-height: 180px; overflow: auto; white-space: pre-wrap; font: inherit; }
1397
+ .pair-result-status-card button { margin-top: 8px; }
1398
+ .pair-result-status-retry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
1399
+ .pair-result-status-retry:disabled { cursor: not-allowed; opacity: 0.55; }
1400
+ .pair-result-status-uncertain, .pair-result-status-blocked { border-color: color-mix(in srgb, var(--warning) 45%, var(--border)); }
1401
+
1390
1402
  /* --- Plan card --- */
1391
1403
  .plan-card {
1392
1404
  max-width: var(--content-width);
@@ -73,6 +73,28 @@ body.pane-mode #input-area {
73
73
  display: flex;
74
74
  }
75
75
 
76
+ /* A configured two-Worker group is a deliberate workspace hierarchy: the
77
+ Driver gets the broad working canvas, while both Workers remain visible as
78
+ equal-height execution surfaces on the right. Legacy/ad-hoc two-pane
79
+ splits keep the ordinary equal columns below. */
80
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) {
81
+ display: grid;
82
+ grid-template-columns: minmax(0, 3fr) minmax(260px, 2fr);
83
+ grid-template-rows: repeat(2, minmax(0, 1fr));
84
+ gap: 1px;
85
+ background: var(--border-subtle);
86
+ }
87
+
88
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-driver {
89
+ grid-row: 1 / span 2;
90
+ grid-column: 1;
91
+ }
92
+
93
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-worker {
94
+ grid-column: 2;
95
+ grid-row: var(--split-worker-row, auto);
96
+ }
97
+
76
98
  .split-pane {
77
99
  flex: 1;
78
100
  display: flex;
@@ -441,6 +463,11 @@ body.pane-mode #input-area {
441
463
  color: var(--text);
442
464
  }
443
465
 
466
+ .split-pane-close:disabled {
467
+ opacity: 0.42;
468
+ cursor: not-allowed;
469
+ }
470
+
444
471
  .split-pane-close svg {
445
472
  width: 12px;
446
473
  height: 12px;
@@ -478,6 +505,25 @@ body.pane-mode #input-area {
478
505
  --split-worker-height: calc(30% - 4.8px);
479
506
  }
480
507
 
508
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) {
509
+ display: flex;
510
+ flex-direction: column;
511
+ gap: var(--split-pane-gap);
512
+ background: transparent;
513
+ }
514
+
515
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-driver,
516
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-worker {
517
+ grid-row: auto;
518
+ grid-column: auto;
519
+ }
520
+
521
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-worker {
522
+ flex: 0 1 24%;
523
+ min-height: 104px;
524
+ order: -1;
525
+ }
526
+
481
527
  #split-host.visible {
482
528
  flex-direction: column;
483
529
  gap: var(--split-pane-gap);
@@ -495,11 +541,11 @@ body.pane-mode #input-area {
495
541
  }
496
542
 
497
543
  .split-pane-role-worker {
498
- order: -1;
544
+ order: var(--split-pane-order, -1);
499
545
  }
500
546
 
501
547
  .split-pane-role-driver {
502
- order: 0;
548
+ order: var(--split-pane-order, 0);
503
549
  }
504
550
 
505
551
  /* A configured Worker is an execution summary; the Driver keeps the