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.
- package/lib/driver-continuation-pair.js +5 -1
- package/lib/multi-worker-feature.js +11 -0
- package/lib/project-pair-autonomous-stop.js +14 -8
- package/lib/project-pair-close-transaction.js +59 -0
- package/lib/project-pair-global-stop.js +18 -0
- package/lib/project-pair-lifecycle-status.js +34 -0
- package/lib/project-pair-lifecycle.js +58 -76
- package/lib/project-pair-message.js +1 -1
- package/lib/project-pair-owned-stop.js +7 -4
- package/lib/project-pair-result-capture.js +41 -11
- package/lib/project-pair-result-delivery.js +4 -3
- package/lib/project-pair-result-outbox.js +73 -4
- package/lib/project-pair-result-recovery.js +6 -2
- package/lib/project-pair-structural-close.js +20 -0
- package/lib/project-pair-task-control.js +39 -18
- package/lib/project-pair-worker-close.js +68 -0
- package/lib/project-session-pair-prompt.js +26 -0
- package/lib/project-session-pair.js +55 -78
- package/lib/project-worker-creation-proposal.js +176 -0
- package/lib/project-worker-permission.js +7 -8
- package/lib/project-worker-proposal-runner.js +45 -0
- package/lib/project-worker-proposal.js +79 -115
- package/lib/project.js +15 -0
- package/lib/public/css/pane.css +48 -2
- package/lib/public/modules/app-connection.js +49 -76
- package/lib/public/modules/app-messages.js +4 -0
- package/lib/public/modules/project-activation.js +10 -7
- package/lib/public/modules/sidebar-sessions.js +31 -11
- package/lib/public/modules/split-group-helpers.js +74 -2
- package/lib/public/modules/split-pair-ui.js +28 -6
- package/lib/public/modules/split-pane-reconciler.js +28 -0
- package/lib/public/modules/split-pane-renderer.js +107 -0
- package/lib/public/modules/split-view.js +58 -188
- package/lib/public/modules/websocket-watchdog.js +126 -0
- package/lib/public/modules/worker-pane-lock.js +13 -3
- package/lib/server.js +1 -0
- package/lib/session-pair-factory.js +66 -0
- package/lib/session-pair-mcp-server.js +8 -3
- package/lib/session-pair-prompts.js +34 -24
- package/lib/session-pair-target.js +42 -0
- package/lib/session-pair-turn-control.js +11 -6
- package/lib/session-split-group-anchors.js +89 -7
- package/lib/session-split-group-persistence.js +136 -0
- package/lib/session-split-group-roles.js +164 -0
- package/lib/session-split-group-v2-store.js +146 -0
- package/lib/session-split-groups.js +109 -99
- package/lib/ws-schema.js +2 -0
- 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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 === "
|
|
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 || !
|
|
367
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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;
|
package/lib/public/css/pane.css
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
probeReconnect("online");
|
|
176
|
+
if (document.hidden) return;
|
|
177
|
+
resumeSocketWatchdogs("online");
|
|
192
178
|
});
|
|
193
179
|
});
|
|
194
180
|
document.addEventListener("visibilitychange", function () {
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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"
|
|
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
|
-
|
|
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);
|