clay-server 4.3.0-beta.5 → 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 (48) hide show
  1. package/lib/driver-continuation-pair.js +5 -1
  2. package/lib/multi-worker-feature.js +11 -0
  3. package/lib/project-pair-autonomous-stop.js +14 -8
  4. package/lib/project-pair-close-transaction.js +59 -0
  5. package/lib/project-pair-global-stop.js +18 -0
  6. package/lib/project-pair-lifecycle-status.js +34 -0
  7. package/lib/project-pair-lifecycle.js +58 -76
  8. package/lib/project-pair-message.js +1 -1
  9. package/lib/project-pair-owned-stop.js +7 -4
  10. package/lib/project-pair-result-capture.js +41 -11
  11. package/lib/project-pair-result-delivery.js +4 -3
  12. package/lib/project-pair-result-outbox.js +73 -4
  13. package/lib/project-pair-result-recovery.js +6 -2
  14. package/lib/project-pair-structural-close.js +20 -0
  15. package/lib/project-pair-task-control.js +39 -18
  16. package/lib/project-pair-worker-close.js +68 -0
  17. package/lib/project-session-pair-prompt.js +26 -0
  18. package/lib/project-session-pair.js +55 -78
  19. package/lib/project-worker-creation-proposal.js +176 -0
  20. package/lib/project-worker-permission.js +7 -8
  21. package/lib/project-worker-proposal-runner.js +45 -0
  22. package/lib/project-worker-proposal.js +79 -115
  23. package/lib/project.js +15 -0
  24. package/lib/public/css/pane.css +48 -2
  25. package/lib/public/modules/app-connection.js +49 -76
  26. package/lib/public/modules/app-messages.js +4 -0
  27. package/lib/public/modules/project-activation.js +10 -7
  28. package/lib/public/modules/sidebar-sessions.js +31 -11
  29. package/lib/public/modules/split-group-helpers.js +74 -2
  30. package/lib/public/modules/split-pair-ui.js +28 -6
  31. package/lib/public/modules/split-pane-reconciler.js +28 -0
  32. package/lib/public/modules/split-pane-renderer.js +107 -0
  33. package/lib/public/modules/split-view.js +58 -188
  34. package/lib/public/modules/websocket-watchdog.js +126 -0
  35. package/lib/public/modules/worker-pane-lock.js +13 -3
  36. package/lib/server.js +1 -0
  37. package/lib/session-pair-factory.js +66 -0
  38. package/lib/session-pair-mcp-server.js +8 -3
  39. package/lib/session-pair-prompts.js +34 -24
  40. package/lib/session-pair-target.js +42 -0
  41. package/lib/session-pair-turn-control.js +11 -6
  42. package/lib/session-split-group-anchors.js +89 -7
  43. package/lib/session-split-group-persistence.js +136 -0
  44. package/lib/session-split-group-roles.js +164 -0
  45. package/lib/session-split-group-v2-store.js +146 -0
  46. package/lib/session-split-groups.js +109 -99
  47. package/lib/ws-schema.js +2 -0
  48. package/package.json +1 -1
@@ -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
@@ -68,6 +68,7 @@ var { attachProjectLogs } = require("./project-logs");
68
68
  var { attachProjectLogFeedbackDelivery } = require("./project-log-feedback-delivery");
69
69
  var { attachProjectMateKnowledge } = require("./project-mate-knowledge");
70
70
  var { attachSplitGroups } = require("./session-split-groups");
71
+ var multiWorkerFeature = require("./multi-worker-feature");
71
72
  var toolControlMcp = require("./tool-control-mcp-server");
72
73
  var capsuleFloor = require("./capsule-display-floor");
73
74
  var toolLlm = require("./tool-llm");
@@ -207,6 +208,7 @@ function safeAbsPath(requested) {
207
208
  * opts: { cwd, slug, title, pushModule, debug, dangerouslySkipPermissions, currentVersion }
208
209
  */
209
210
  function createProjectContext(opts) {
211
+ var multiWorkerGate = multiWorkerFeature.fromServerConfig(opts);
210
212
  var cwd = opts.cwd;
211
213
  var slug = opts.slug;
212
214
  var project = path.basename(cwd);
@@ -661,6 +663,7 @@ function createProjectContext(opts) {
661
663
  clients: clients,
662
664
  sendTo: sendTo,
663
665
  usersModule: usersModule,
666
+ multiWorkerFeature: multiWorkerGate,
664
667
  onPairChanged: function (group) {
665
668
  if (!sdk || !group || !Array.isArray(group.members)) return;
666
669
  for (var i = 0; i < group.members.length; i++) {
@@ -828,6 +831,7 @@ function createProjectContext(opts) {
828
831
  sm: sm,
829
832
  isMate: isMate,
830
833
  splitStore: _splitGroups.store,
834
+ multiWorkerFeature: multiWorkerGate,
831
835
  getSdk: function () { return sdk; },
832
836
  send: send,
833
837
  sendTo: sendTo,
@@ -851,6 +855,16 @@ function createProjectContext(opts) {
851
855
  if (_autonomousRun) _autonomousRun.onPartnerResult(driver, worker, token);
852
856
  },
853
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
+ });
854
868
  _pairResultRecovery = attachPairResultRecovery({
855
869
  sm: sm, store: _splitGroups.store, outbox: _pairResultOutbox, resultCapture: _sessionPair.resultCapture,
856
870
  wake: _sessionPair.wakePartnerResult, projectSlug: slug, sendTo: sendTo,
@@ -1757,6 +1771,7 @@ function createProjectContext(opts) {
1757
1771
 
1758
1772
  // --- Sessions, config, project mgmt (delegated to project-sessions.js) ---
1759
1773
  if (_pairResultRecovery.handleMessage(ws, msg)) return;
1774
+ if (_pairWorkerClose.handleMessage(ws, msg)) return;
1760
1775
  if (_sessionPair.handleMessage(ws, msg)) return;
1761
1776
  if (_sessionHandoff.handleMessage(ws, msg)) return;
1762
1777
  if (_driverContinuation.handleMessage(ws, msg)) return;
@@ -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
@@ -23,6 +23,7 @@ import { clearProjectSplitState } from './split-session-boundary.js';
23
23
  import { clearPermissionModePending } from './permission-control.js';
24
24
  import { clearMcpPermissionModePending } from './mcp-ui.js';
25
25
  import { createWebSocketLifecycle } from './websocket-lifecycle.js';
26
+ import { createWebSocketWatchdog } from './websocket-watchdog.js';
26
27
 
27
28
  var connectOverlay = null;
28
29
  var hasConnectedOnce = false;
@@ -42,16 +43,9 @@ var overlayGraceTimer = null;
42
43
  // server-global-ws.js. Well under the common 60s proxy read timeout, and one
43
44
  // timer per live socket.
44
45
  var HEARTBEAT_MS = 25000;
45
- var heartbeatTimer = null;
46
- var heartbeatDeadlineTimer = null;
47
- var heartbeatAwaitingSocket = null;
48
46
  var disconnectedAt = 0;
49
47
  var attemptStartedAt = 0;
50
48
  var attemptSocket = null;
51
- var probeTimer = null;
52
- var probeSocket = null;
53
- var probeEpoch = 0;
54
- var probeStartedAt = 0;
55
49
  var lifecycle = createWebSocketLifecycle({
56
50
  handshakeTimeoutMs: 12000,
57
51
  onHandshakeTimeout: function (epoch) {
@@ -60,6 +54,21 @@ var lifecycle = createWebSocketLifecycle({
60
54
  }
61
55
  }
62
56
  });
57
+ var watchdog = createWebSocketWatchdog({
58
+ heartbeatIntervalMs: HEARTBEAT_MS,
59
+ heartbeatDeadlineMs: 10000,
60
+ probeTimeoutMs: 4000,
61
+ now: function () { return Date.now(); },
62
+ isCurrent: function (socket, epoch) {
63
+ return getWs() === socket && socket.readyState === 1 && lifecycle.current(epoch);
64
+ },
65
+ sendPing: function (socket) {
66
+ socket.send(JSON.stringify({ type: "ping" }));
67
+ },
68
+ onFailure: function (reason, epoch, latency) {
69
+ forceReplaceSocket(reason, 0, epoch, latency);
70
+ }
71
+ });
63
72
 
64
73
  function clearOverlayGrace() {
65
74
  if (overlayGraceTimer) {
@@ -88,19 +97,10 @@ function recordConnectionDiagnostic(reason, code, latency) {
88
97
  });
89
98
  }
90
99
 
91
- function clearProbe() {
92
- if (probeTimer) clearTimeout(probeTimer);
93
- probeTimer = null;
94
- probeSocket = null;
95
- probeEpoch = 0;
96
- probeStartedAt = 0;
97
- }
98
-
99
- function forceReplaceSocket(reason, code, epoch) {
100
+ function forceReplaceSocket(reason, code, epoch, latency) {
100
101
  if (!lifecycle.current(epoch)) return;
101
102
  var socket = getWs();
102
- var latency = probeStartedAt ? Date.now() - probeStartedAt : null;
103
- clearProbe();
103
+ watchdog.clearProbe();
104
104
  stopHeartbeat();
105
105
  if (socket) {
106
106
  socket.onopen = null;
@@ -117,19 +117,11 @@ function forceReplaceSocket(reason, code, epoch) {
117
117
  }
118
118
 
119
119
  export function stopHeartbeat() {
120
- if (heartbeatTimer) {
121
- clearInterval(heartbeatTimer);
122
- heartbeatTimer = null;
123
- }
124
- if (heartbeatDeadlineTimer) {
125
- clearTimeout(heartbeatDeadlineTimer);
126
- heartbeatDeadlineTimer = null;
127
- }
128
- heartbeatAwaitingSocket = null;
120
+ watchdog.stopHeartbeat();
129
121
  }
130
122
 
131
123
  function suspendSocketForOffline() {
132
- clearProbe();
124
+ watchdog.clearProbe();
133
125
  stopHeartbeat();
134
126
  var socket = getWs();
135
127
  attemptSocket = null;
@@ -150,31 +142,26 @@ function suspendSocketForOffline() {
150
142
  // timeout from expiring. A missing pong also identifies a half-open socket,
151
143
  // which browsers can otherwise leave OPEN while silently losing sends.
152
144
  export function startHeartbeat(socket, attemptEpoch) {
153
- stopHeartbeat();
154
- if (!socket) return;
155
- heartbeatTimer = setInterval(function () {
156
- if (!socket || socket.readyState !== 1 || getWs() !== socket || !lifecycle.current(attemptEpoch)) {
157
- stopHeartbeat();
158
- return;
159
- }
160
- try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {
161
- forceReplaceSocket("heartbeat_error", 0, attemptEpoch);
162
- return;
163
- }
164
- heartbeatAwaitingSocket = socket;
165
- if (heartbeatDeadlineTimer) clearTimeout(heartbeatDeadlineTimer);
166
- heartbeatDeadlineTimer = setTimeout(function () {
167
- heartbeatDeadlineTimer = null;
168
- if (getWs() === socket && socket.readyState === 1 && lifecycle.current(attemptEpoch)) {
169
- forceReplaceSocket("heartbeat_timeout", 0, attemptEpoch);
170
- }
171
- }, 10000);
172
- }, HEARTBEAT_MS);
145
+ watchdog.startHeartbeat(socket, attemptEpoch);
146
+ }
147
+
148
+ function suspendSocketWatchdogs() {
149
+ // A frozen page cannot promise proxy keepalive. Retire its deadlines without
150
+ // closing the socket; resume performs a fresh bounded liveness check.
151
+ watchdog.suspend();
152
+ }
153
+
154
+ function resumeSocketWatchdogs(reason) {
155
+ var resumed = watchdog.resume();
156
+ var socket = getWs();
157
+ if (socket && socket.readyState === 1 && resumed) startHeartbeat(socket, lifecycle.getEpoch());
158
+ probeReconnect(reason);
173
159
  }
174
160
 
175
161
  export function initConnection() {
176
162
  connectOverlay = document.getElementById("connect-overlay");
177
163
  if (typeof navigator !== "undefined" && navigator.onLine === false) lifecycle.setOffline(true);
164
+ if (document.hidden) suspendSocketWatchdogs();
178
165
  window.addEventListener("clay-message-delivery-timeout", function (event) {
179
166
  var socket = event.detail && event.detail.socket;
180
167
  recordConnectionDiagnostic("ack-timeout", 0, null);
@@ -186,17 +173,19 @@ export function initConnection() {
186
173
  });
187
174
  window.addEventListener("online", function () {
188
175
  lifecycle.wake(function () {
189
- var socket = getWs();
190
- if (socket && socket.readyState === 1) startHeartbeat(socket, lifecycle.getEpoch());
191
- probeReconnect("online");
176
+ if (document.hidden) return;
177
+ resumeSocketWatchdogs("online");
192
178
  });
193
179
  });
194
180
  document.addEventListener("visibilitychange", function () {
195
- if (!document.hidden) {
196
- var socket = getWs();
197
- if (socket && socket.readyState === 1) startHeartbeat(socket, lifecycle.getEpoch());
198
- probeReconnect("visible");
199
- }
181
+ if (document.hidden) suspendSocketWatchdogs();
182
+ else resumeSocketWatchdogs("visible");
183
+ });
184
+ document.addEventListener("freeze", function () {
185
+ suspendSocketWatchdogs();
186
+ });
187
+ document.addEventListener("resume", function () {
188
+ if (!document.hidden) resumeSocketWatchdogs("visible");
200
189
  });
201
190
 
202
191
  // --- Reactive UI sync for connected/processing state ---
@@ -379,7 +368,7 @@ export function connect() {
379
368
  newWs.onclose = function (e) {
380
369
  if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
381
370
  stopHeartbeat();
382
- clearProbe();
371
+ watchdog.clearProbe();
383
372
  disconnectedAt = Date.now();
384
373
  recordConnectionDiagnostic("close", e && e.code, attemptStartedAt ? Date.now() - attemptStartedAt : null);
385
374
  closeDmUserPicker();
@@ -408,18 +397,13 @@ export function connect() {
408
397
  var msg;
409
398
  try { msg = JSON.parse(event.data); } catch (e) { return; }
410
399
  lifecycle.markLive(attemptEpoch);
411
- if (msg.type === "pong" && heartbeatDeadlineTimer && heartbeatAwaitingSocket === newWs) {
412
- clearTimeout(heartbeatDeadlineTimer);
413
- heartbeatDeadlineTimer = null;
414
- heartbeatAwaitingSocket = null;
415
- }
416
- if (msg.type === "pong" && probeSocket === newWs && lifecycle.current(probeEpoch)) clearProbe();
400
+ if (msg.type === "pong") watchdog.acceptPong(newWs, attemptEpoch);
417
401
  processMessage(msg);
418
402
  };
419
403
  }
420
404
 
421
405
  export function cancelReconnect() {
422
- clearProbe();
406
+ watchdog.clearProbe();
423
407
  stopHeartbeat();
424
408
  lifecycle.cancel();
425
409
  }
@@ -429,18 +413,7 @@ function probeReconnect(reason) {
429
413
  var socket = getWs();
430
414
  if (socket && socket.readyState === 0) return;
431
415
  if (socket && socket.readyState === 1) {
432
- if (probeTimer) return;
433
- probeSocket = socket;
434
- probeEpoch = lifecycle.getEpoch();
435
- probeStartedAt = Date.now();
436
- try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {
437
- forceReplaceSocket("probe_error", 0, probeEpoch);
438
- return;
439
- }
440
- probeTimer = setTimeout(function () {
441
- probeTimer = null;
442
- if (probeSocket === socket && lifecycle.current(probeEpoch) && getWs() === socket && socket.readyState === 1) forceReplaceSocket("probe_timeout", 0, probeEpoch);
443
- }, 4000);
416
+ watchdog.probe(socket, lifecycle.getEpoch());
444
417
  return;
445
418
  }
446
419
  scheduleReconnect(reason);