clay-server 4.2.0 → 4.3.0-beta.10

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 (84) hide show
  1. package/lib/driver-continuation-access.js +54 -0
  2. package/lib/driver-continuation-lease.js +52 -0
  3. package/lib/driver-continuation-pair-transfer.js +86 -0
  4. package/lib/driver-continuation-pair.js +86 -0
  5. package/lib/driver-continuation-record.js +40 -0
  6. package/lib/driver-continuation-startup.js +68 -0
  7. package/lib/driver-continuation-transaction.js +61 -0
  8. package/lib/driver-continuation-trigger.js +110 -0
  9. package/lib/multi-worker-feature.js +11 -0
  10. package/lib/project-connection.js +3 -1
  11. package/lib/project-driver-continuation.js +499 -0
  12. package/lib/project-message-delivery.js +20 -0
  13. package/lib/project-pair-autonomous-stop.js +14 -8
  14. package/lib/project-pair-close-transaction.js +59 -0
  15. package/lib/project-pair-global-stop.js +18 -0
  16. package/lib/project-pair-lifecycle-status.js +34 -0
  17. package/lib/project-pair-lifecycle.js +117 -135
  18. package/lib/project-pair-message.js +1 -1
  19. package/lib/project-pair-owned-stop.js +7 -4
  20. package/lib/project-pair-replacement-state.js +5 -1
  21. package/lib/project-pair-result-capture.js +116 -0
  22. package/lib/project-pair-result-delivery.js +140 -0
  23. package/lib/project-pair-result-outbox.js +400 -0
  24. package/lib/project-pair-result-pipeline.js +16 -0
  25. package/lib/project-pair-result-recovery.js +229 -0
  26. package/lib/project-pair-structural-close.js +20 -0
  27. package/lib/project-pair-task-control.js +45 -18
  28. package/lib/project-pair-worker-close.js +68 -0
  29. package/lib/project-session-handoff.js +48 -55
  30. package/lib/project-session-pair-prompt.js +26 -0
  31. package/lib/project-session-pair.js +87 -106
  32. package/lib/project-sessions.js +4 -0
  33. package/lib/project-user-message.js +21 -0
  34. package/lib/project-worker-creation-proposal.js +176 -0
  35. package/lib/project-worker-permission.js +7 -8
  36. package/lib/project-worker-proposal-runner.js +45 -0
  37. package/lib/project-worker-proposal.js +79 -115
  38. package/lib/project.js +87 -3
  39. package/lib/public/app.js +1 -0
  40. package/lib/public/css/driver-continuation.css +56 -0
  41. package/lib/public/css/messages.css +12 -0
  42. package/lib/public/css/mobile-nav.css +29 -14
  43. package/lib/public/css/overlays.css +54 -25
  44. package/lib/public/css/pane.css +66 -12
  45. package/lib/public/css/sidebar.css +62 -7
  46. package/lib/public/index.html +3 -3
  47. package/lib/public/modules/app-connection.js +165 -74
  48. package/lib/public/modules/app-messages.js +31 -3
  49. package/lib/public/modules/driver-continuation-state.js +76 -0
  50. package/lib/public/modules/driver-continuation.js +260 -0
  51. package/lib/public/modules/message-delivery-ui.js +63 -0
  52. package/lib/public/modules/message-delivery.js +163 -6
  53. package/lib/public/modules/pair-result-status.js +87 -0
  54. package/lib/public/modules/permission-control.js +10 -5
  55. package/lib/public/modules/project-activation.js +10 -7
  56. package/lib/public/modules/session-creation-cta.js +46 -0
  57. package/lib/public/modules/sidebar-mobile.js +42 -30
  58. package/lib/public/modules/sidebar-sessions.js +77 -52
  59. package/lib/public/modules/split-group-helpers.js +74 -2
  60. package/lib/public/modules/split-pair-ui.js +28 -6
  61. package/lib/public/modules/split-pane-reconciler.js +28 -0
  62. package/lib/public/modules/split-pane-renderer.js +107 -0
  63. package/lib/public/modules/split-view.js +58 -188
  64. package/lib/public/modules/websocket-lifecycle.js +178 -0
  65. package/lib/public/modules/websocket-watchdog.js +126 -0
  66. package/lib/public/modules/worker-pane-lock.js +13 -3
  67. package/lib/public/style.css +1 -0
  68. package/lib/sdk-bridge.js +77 -0
  69. package/lib/server.js +1 -0
  70. package/lib/session-handoff-discovery.js +443 -0
  71. package/lib/session-handoff-mcp-server.js +32 -4
  72. package/lib/session-pair-factory.js +66 -0
  73. package/lib/session-pair-mcp-server.js +10 -5
  74. package/lib/session-pair-prompts.js +46 -25
  75. package/lib/session-pair-target.js +42 -0
  76. package/lib/session-pair-turn-control.js +30 -9
  77. package/lib/session-split-group-anchors.js +126 -0
  78. package/lib/session-split-group-persistence.js +136 -0
  79. package/lib/session-split-group-roles.js +164 -0
  80. package/lib/session-split-group-v2-store.js +146 -0
  81. package/lib/session-split-groups.js +293 -133
  82. package/lib/sessions.js +89 -22
  83. package/lib/ws-schema.js +8 -0
  84. package/package.json +1 -1
@@ -0,0 +1,176 @@
1
+ var crypto = require("crypto");
2
+ var driverEligibility = require("./session-driver-eligibility");
3
+ var multiWorkerFeature = require("./multi-worker-feature");
4
+ var proposalControl = require("./worker-proposal-control");
5
+ var splitRoles = require("./session-split-group-roles");
6
+
7
+ function generationOf(session) {
8
+ return session && (session._pairGeneration ||
9
+ session.sessionProvenance && session.sessionProvenance.generation) || null;
10
+ }
11
+
12
+ function captureMembership(sm, driver, group) {
13
+ var roles = splitRoles.resolveLiveRoles(sm, group);
14
+ if (!roles || roles.driver !== driver || roles.role.kind === "adhoc" || !driver.sessionOriginId) return null;
15
+ var workers = [];
16
+ for (var i = 0; i < roles.workers.length; i++) {
17
+ if (!roles.workers[i].sessionOriginId || !Number.isInteger(generationOf(roles.workers[i]))) return null;
18
+ workers.push({ id: roles.workers[i].localId, originId: roles.workers[i].sessionOriginId,
19
+ generation: generationOf(roles.workers[i]) });
20
+ }
21
+ return { groupId: group.id, ownerId: driver.ownerId || null, driverId: driver.localId,
22
+ driverOriginId: driver.sessionOriginId, workerIds: roles.role.workerIds.slice(), workers: workers };
23
+ }
24
+
25
+ function matchesMembership(sm, driver, group, snapshot) {
26
+ if (!snapshot || !driver || !group || group.id !== snapshot.groupId || driver.localId !== snapshot.driverId ||
27
+ (driver.ownerId || null) !== snapshot.ownerId || driver.sessionOriginId !== snapshot.driverOriginId) return false;
28
+ var current = captureMembership(sm, driver, group);
29
+ if (!current || current.workerIds.join(",") !== snapshot.workerIds.join(",")) return false;
30
+ for (var i = 0; i < current.workers.length; i++) {
31
+ var expected = snapshot.workers[i];
32
+ if (!expected || current.workers[i].id !== expected.id || current.workers[i].originId !== expected.originId ||
33
+ current.workers[i].generation !== expected.generation) return false;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ function attachWorkerCreationProposal(ctx) {
39
+ var sm = ctx.sm;
40
+ var store = ctx.splitStore;
41
+
42
+ function initialSnapshot(session) {
43
+ if (!ctx.canOffer(session)) return { error: "Split Worker proposals require an eligible Driver session." };
44
+ var group = store.groupForMember(session.localId);
45
+ if (!group) return { kind: "initial", driver: session };
46
+ if (!multiWorkerFeature.isEnabled(ctx.multiWorkerFeature)) {
47
+ return { error: "This Driver already has a Split Worker." };
48
+ }
49
+ var roles = splitRoles.resolveLiveRoles(sm, group);
50
+ if (!roles || roles.driver !== session || roles.role.kind === "adhoc") {
51
+ return { error: "The exact Driver/Split Worker membership is not eligible for another Worker." };
52
+ }
53
+ if (roles.workers.length >= 2) return { error: "A Driver can have at most two Split Workers." };
54
+ if (roles.workers.length !== 1) return { error: "The existing Split Worker membership is invalid." };
55
+ if (!session.sessionOriginId || !roles.workers[0].sessionOriginId || !Number.isInteger(generationOf(roles.workers[0]))) {
56
+ return { error: "The exact Driver and Split Worker identities are not durable enough to add another Worker." };
57
+ }
58
+ return {
59
+ kind: "add",
60
+ group: group,
61
+ groupId: group.id,
62
+ ownerId: session.ownerId || null,
63
+ driverId: session.localId,
64
+ driverOriginId: session.sessionOriginId || null,
65
+ workerIds: roles.role.workerIds.slice(),
66
+ workers: roles.workers.map(function (worker) {
67
+ return { id: worker.localId, originId: worker.sessionOriginId || null, generation: generationOf(worker) };
68
+ }),
69
+ };
70
+ }
71
+
72
+ function validateSnapshot(session, snapshot, requireSameObject) {
73
+ if (!snapshot || snapshot.kind === "initial") {
74
+ return ctx.isEligible(session) ? { ok: true, group: null } : { ok: false };
75
+ }
76
+ if (!multiWorkerFeature.isEnabled(ctx.multiWorkerFeature) || !ctx.canOffer(session) ||
77
+ (session.ownerId || null) !== snapshot.ownerId || session.localId !== snapshot.driverId ||
78
+ (session.sessionOriginId || null) !== snapshot.driverOriginId) return { ok: false };
79
+ var group = store.groupForMember(session.localId);
80
+ if (!group || group.id !== snapshot.groupId || requireSameObject && group !== snapshot.group) return { ok: false };
81
+ var roles = splitRoles.resolveLiveRoles(sm, group);
82
+ if (!roles || roles.role.kind === "adhoc" || roles.driver !== session || roles.role.workerIds.length !== 1 ||
83
+ roles.role.workerIds.join(",") !== snapshot.workerIds.join(",")) return { ok: false };
84
+ for (var i = 0; i < snapshot.workers.length; i++) {
85
+ var expected = snapshot.workers[i];
86
+ var worker = sm.sessions.get(expected.id);
87
+ if (!worker || roles.workers.indexOf(worker) === -1 ||
88
+ (worker.ownerId || null) !== snapshot.ownerId ||
89
+ (worker.sessionOriginId || null) !== expected.originId || generationOf(worker) !== expected.generation) {
90
+ return { ok: false };
91
+ }
92
+ }
93
+ return { ok: true, group: group, roles: roles };
94
+ }
95
+
96
+ function snapshotFromProposal(proposal) {
97
+ if (!proposal || proposal.action !== "add") return { kind: "initial" };
98
+ return {
99
+ kind: "add",
100
+ groupId: proposal.sourceGroupId,
101
+ ownerId: proposal.sourceOwnerId || null,
102
+ driverId: proposal.sourceDriverId,
103
+ driverOriginId: proposal.sourceDriverOriginId || null,
104
+ workerIds: Array.isArray(proposal.sourceWorkerIds) ? proposal.sourceWorkerIds.slice() : [],
105
+ workers: Array.isArray(proposal.sourceWorkers) ? proposal.sourceWorkers.slice() : [],
106
+ };
107
+ }
108
+
109
+ async function propose(args, session) {
110
+ var snapshot = initialSnapshot(session);
111
+ if (snapshot.error) return ctx.toolResult({ error: snapshot.error });
112
+ var superseded = proposalControl.pendingProposal(session);
113
+ var supersedeId = typeof args.supersedeProposalId === "string" ? args.supersedeProposalId.trim() : "";
114
+ if (superseded && superseded.proposalId !== supersedeId) return ctx.toolResult({ error: "A Split Worker suggestion is already awaiting a decision. Inspect it or supply its exact id to supersede it." });
115
+ if (!superseded && supersedeId) return ctx.toolResult({ error: "The Split Worker suggestion to supersede is no longer pending." });
116
+ if (superseded && superseded.status !== "pending") return ctx.toolResult({ error: "The Split Worker suggestion is already starting and cannot be superseded." });
117
+ var summary = typeof args.summary === "string" ? args.summary.trim() : "";
118
+ var plan = typeof args.plan === "string" ? args.plan.trim() : "";
119
+ var task = typeof args.message === "string" ? args.message.trim() : "";
120
+ var rationale = typeof args.recommendationRationale === "string" ? args.recommendationRationale.trim() : "";
121
+ if (!summary || !plan || !task || !rationale) return ctx.toolResult({ error: "summary, plan, message, and recommendationRationale are required." });
122
+ if (task.length > ctx.maxTaskChars) return ctx.toolResult({ error: "The Split Worker task is too long." });
123
+ await ctx.ensureModelCatalogs();
124
+ if (!validateSnapshot(session, snapshot, true).ok) {
125
+ return ctx.toolResult({ error: "The Driver/Split Worker membership changed while runtimes were loading." });
126
+ }
127
+ if (proposalControl.pendingProposal(session) !== superseded) return ctx.toolResult({ error: "The pending Split Worker suggestion changed while runtimes were loading." });
128
+ var options = ctx.proposalOptions();
129
+ if (options.installedVendors.length === 0) return ctx.toolResult({ error: "No coding agent is installed for a Split Worker session." });
130
+ var recommendation = ctx.chooseRecommendation(args, session, options);
131
+ var proposal = {
132
+ type: "worker_proposal", proposalId: "worker_" + crypto.randomUUID(),
133
+ summary: summary.slice(0, ctx.maxSummaryChars), plan: plan.slice(0, ctx.maxPlanChars), message: task,
134
+ status: "pending", createdAt: Date.now(), recommendedVendor: recommendation.vendor,
135
+ recommendedModel: recommendation.model, recommendedEffort: recommendation.effort,
136
+ recommendationRationale: rationale.slice(0, ctx.maxRationaleChars), options: options,
137
+ };
138
+ if (snapshot.kind === "add") Object.assign(proposal, {
139
+ action: "add", sourceGroupId: snapshot.groupId, sourceOwnerId: snapshot.ownerId,
140
+ sourceDriverId: snapshot.driverId, sourceDriverOriginId: snapshot.driverOriginId,
141
+ sourceWorkerIds: snapshot.workerIds.slice(), sourceWorkers: snapshot.workers.slice(),
142
+ });
143
+ if (superseded) ctx.updateProposal(session, superseded, { status: "superseded", supersededBy: proposal.proposalId });
144
+ sm.sendAndRecord(session, proposal);
145
+ if (ctx.skipPermissionsEnabled(session) && ctx.recommendationCanAutoAccept(args, recommendation, options)) {
146
+ var accepted = await ctx.acceptProposal(session, proposal, {
147
+ vendor: recommendation.vendor, model: recommendation.model, effort: recommendation.effort,
148
+ }, ctx.autoAcceptanceWs(session), true);
149
+ return ctx.toolResult({ status: accepted.ok ? "auto_accepted" : "posted", proposalId: proposal.proposalId,
150
+ instruction: accepted.ok ? "The recorded Split Worker configuration was auto-accepted. End this turn while the exact Worker runs." :
151
+ "The automatic decision failed closed. The configuration card remains pending for the user." });
152
+ }
153
+ return ctx.toolResult({ status: "posted", proposalId: proposal.proposalId,
154
+ instruction: "The Split Worker suggestion is visible in the chat. End this turn now and wait for the user's decision." });
155
+ }
156
+
157
+ function canMountProposal(session) {
158
+ if (!ctx.canOffer(session)) return false;
159
+ var group = store.groupForMember(session.localId);
160
+ if (!group) return true;
161
+ if (!multiWorkerFeature.isEnabled(ctx.multiWorkerFeature)) return false;
162
+ var roles = splitRoles.resolveLiveRoles(sm, group);
163
+ return !!roles && roles.role.kind !== "adhoc" && roles.driver === session;
164
+ }
165
+
166
+ return {
167
+ propose: propose,
168
+ canMountProposal: canMountProposal,
169
+ validateAcceptance: function (session, proposal) {
170
+ return validateSnapshot(session, snapshotFromProposal(proposal), false);
171
+ },
172
+ };
173
+ }
174
+
175
+ module.exports = { attachWorkerCreationProposal: attachWorkerCreationProposal,
176
+ captureMembership: captureMembership, matchesMembership: matchesMembership };
@@ -43,6 +43,7 @@
43
43
  // same user. Anything else is refused and the request stays pending.
44
44
 
45
45
  var crypto = require("crypto");
46
+ var splitRoles = require("./session-split-group-roles");
46
47
 
47
48
  // Tools that collect input from the person rather than authorize an action.
48
49
  // These are not permission approvals, so they are never routed to a Driver;
@@ -104,20 +105,18 @@ function attachWorkerPermission(ctx) {
104
105
  if (sm.sessions.get(session.localId) !== session) return null;
105
106
  var group = store.groupForMember(session.localId);
106
107
  if (!group || !group.pair) return null;
107
- if (group.pair.workerId !== session.localId) return null;
108
- var driver = sm.sessions.get(group.pair.driverId);
109
- if (!driver) return null;
110
- if ((driver.ownerId || null) !== (session.ownerId || null)) return null;
111
- if (driver.destroying) return null;
112
- return { group: group, driver: driver };
108
+ var roles = splitRoles.resolveLiveRoles(sm, group);
109
+ if (!roles || roles.workers.indexOf(session) === -1) return null;
110
+ return { group: group, driver: roles.driver };
113
111
  }
114
112
 
115
113
  // Is the record's pair still exactly the pair it was created for?
116
114
  function pairStillExact(record) {
117
115
  var group = store ? store.groupForMember(record.workerId) : null;
118
116
  if (!group || group.id !== record.groupId || !group.pair) return false;
119
- if (group.pair.driverId !== record.driverId) return false;
120
- if (group.pair.workerId !== record.workerId) return false;
117
+ var roles = splitRoles.resolveLiveRoles(sm, group);
118
+ if (!roles || roles.driver.localId !== record.driverId) return false;
119
+ if (roles.workers.indexOf(record.workerRef) === -1 || record.workerRef.localId !== record.workerId) return false;
121
120
  var driver = sm.sessions.get(record.driverId);
122
121
  var worker = sm.sessions.get(record.workerId);
123
122
  if (!driver || !worker || driver.destroying || worker.destroying) return false;
@@ -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]\nInspect the failure and delegate a narrower follow-up to the Split Worker when implementation work remains.\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
  }