clay-server 3.2.2-beta.2 → 3.3.0-beta.1

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.
@@ -1,4 +1,5 @@
1
1
  var pairMcp = require("./session-pair-mcp-server");
2
+ var { attachWorkerProposal } = require("./project-worker-proposal");
2
3
  var yoke = require("./yoke");
3
4
 
4
5
  var MAX_RESPONSE_CHARS = 30000;
@@ -57,6 +58,7 @@ function recentTurns(session, count) {
57
58
  function attachSessionPair(ctx) {
58
59
  var sm = ctx.sm;
59
60
  var store = ctx.splitStore;
61
+ var workerProposal;
60
62
 
61
63
  function groupAndPartner(caller) {
62
64
  if (!caller) throw new Error("partner tools require a session-bound tool server");
@@ -91,6 +93,63 @@ function attachSessionPair(ctx) {
91
93
  broadcastDelegation(group, caller, partner, false);
92
94
  }
93
95
 
96
+ function resumeDriverWithResult(caller, partner, token) {
97
+ if (!token.detached || token.delivered || caller.destroying) return false;
98
+ token.delivered = true;
99
+ var failure = token.failure || null;
100
+ var response = token.response || "";
101
+ var text = "A Worker task delegated through send_to_partner has finished.\n\n" +
102
+ "Original task:\n" + token.message + "\n\n" +
103
+ (failure ? "Worker error:\n" + failure : "Worker result:\n" + (response || "(No text response was recorded.)")) +
104
+ "\n\nReview the result, verify it as needed, and continue the task.";
105
+ sm.sendAndRecord(caller, {
106
+ type: "user_message",
107
+ text: text,
108
+ _internal: true,
109
+ partnerResult: true,
110
+ partnerSessionId: partner.localId,
111
+ });
112
+ caller.lastActivity = Date.now();
113
+ var sdk = ctx.getSdk();
114
+ if (!sdk) {
115
+ sm.sendAndRecord(caller, { type: "error", text: "Could not resume the Driver: SDK bridge is not ready" });
116
+ return false;
117
+ }
118
+ if (!caller.isProcessing) {
119
+ caller.isProcessing = true;
120
+ caller.sentToolResults = {};
121
+ ctx.onProcessingChanged();
122
+ sm.sendToSession(caller, { type: "status", status: "processing" });
123
+ }
124
+ if (!sdk.pushMessage(caller, text)) {
125
+ caller._queryStartTs = Date.now();
126
+ Promise.resolve(sdk.startQuery(caller, text, undefined, ctx.getLinuxUserForSession(caller))).catch(function (err) {
127
+ caller.isProcessing = false;
128
+ sm.sendAndRecord(caller, { type: "error", text: err.message || String(err) });
129
+ ctx.onProcessingChanged();
130
+ });
131
+ }
132
+ sm.broadcastSessionList();
133
+ return true;
134
+ }
135
+
136
+ function handleTurnDone(partner) {
137
+ var token = partner && partner._pairDelegation;
138
+ if (!token) return false;
139
+ var group = store.groupForMember(partner.localId);
140
+ if (!group || group.id !== token.groupId || group.members.indexOf(token.from) === -1) return false;
141
+ var caller = sm.sessions.get(token.from);
142
+ if (!caller || caller.ownerId !== partner.ownerId) return false;
143
+ if (group.pair && (group.pair.driverId !== caller.localId || group.pair.workerId !== partner.localId)) {
144
+ finishDelegation(group, caller, partner, token);
145
+ return false;
146
+ }
147
+ token.response = responseText(partner.history || [], token.startIndex);
148
+ token.failure = errorSince(partner.history || [], token.startIndex);
149
+ finishDelegation(group, caller, partner, token);
150
+ return resumeDriverWithResult(caller, partner, token);
151
+ }
152
+
94
153
  function waitForPartner(group, caller, partner, token, timeoutSeconds) {
95
154
  return new Promise(function (resolve, reject) {
96
155
  var deadline = Date.now() + timeoutSeconds * 1000;
@@ -115,8 +174,9 @@ function attachSessionPair(ctx) {
115
174
  }
116
175
  if (Date.now() >= deadline) {
117
176
  clearInterval(timer);
177
+ token.detached = true;
118
178
  monitorPartner(group, caller, partner, token);
119
- resolve({ status: "running", response: responseText(partner.history || [], token.startIndex), hint: "Use read_partner to check again." });
179
+ resolve({ status: "running", response: responseText(partner.history || [], token.startIndex), hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
120
180
  }
121
181
  }, 500);
122
182
  });
@@ -127,7 +187,8 @@ function attachSessionPair(ctx) {
127
187
  var currentGroup = store.groupForMember(caller.localId);
128
188
  if (!currentGroup || currentGroup.id !== group.id || (!partner.isProcessing && !partner._queryStarting)) {
129
189
  clearInterval(timer);
130
- finishDelegation(group, caller, partner, token);
190
+ if (currentGroup && currentGroup.id === group.id) handleTurnDone(partner);
191
+ else finishDelegation(group, caller, partner, token);
131
192
  }
132
193
  }, 500);
133
194
  }
@@ -143,7 +204,7 @@ function attachSessionPair(ctx) {
143
204
  timeout = Math.max(1, Math.min(900, timeout));
144
205
  var partner = resolved.partner;
145
206
  if (partner._pairDelegation) throw new Error("the partner is already handling a delegated task");
146
- var token = { from: caller.localId, groupId: resolved.group.id, startIndex: partner.history.length };
207
+ var token = { from: caller.localId, groupId: resolved.group.id, startIndex: partner.history.length, message: message };
147
208
  partner._pairDelegation = token;
148
209
  partner._delegatedBy = caller.localId;
149
210
  sm.sendAndRecord(partner, {
@@ -173,8 +234,9 @@ function attachSessionPair(ctx) {
173
234
  }
174
235
  sm.broadcastSessionList();
175
236
  if (!wait) {
237
+ token.detached = true;
176
238
  monitorPartner(resolved.group, caller, partner, token);
177
- return toolResult({ status: "running", partnerId: partner.localId, hint: "Use read_partner to check progress." });
239
+ return toolResult({ status: "running", partnerId: partner.localId, hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
178
240
  }
179
241
  return toolResult(await waitForPartner(resolved.group, caller, partner, token, timeout));
180
242
  } catch (e) {
@@ -223,10 +285,11 @@ function attachSessionPair(ctx) {
223
285
  // pair worker at query start never sees the tools.
224
286
  var group = store.groupForMember(boundSession.localId);
225
287
  if (group && group.pair && group.pair.driverId !== boundSession.localId) return [];
226
- return pairMcp.getToolDefs({
288
+ var tools = pairMcp.getToolDefs({
227
289
  send: function (args) { return sendToPartner(args, boundSession); },
228
290
  read: function (args) { return readPartner(args, boundSession); },
229
291
  });
292
+ return tools.concat(workerProposal.getToolDefs(boundSession));
230
293
  }
231
294
 
232
295
  function validateVendor(vendor) {
@@ -235,49 +298,69 @@ function attachSessionPair(ctx) {
235
298
  return vendor;
236
299
  }
237
300
 
301
+ function createPairRecord(ws, msg) {
302
+ if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
303
+ var driverSpec = msg.driver || {};
304
+ var workerSpec = msg.worker || {};
305
+ var workerVendor = validateVendor(workerSpec.vendor || sm.lastVendor || "codex");
306
+ var ownerId = ws._clayUser && ctx.usersModule.isMultiUser() ? ws._clayUser.id : null;
307
+ var driver;
308
+ var groupName;
309
+ if (Number.isInteger(driverSpec.sessionId)) {
310
+ // "Add Worker" on an existing session: the current session becomes
311
+ // the Driver with its full conversation context intact.
312
+ driver = sm.sessions.get(driverSpec.sessionId);
313
+ if (!driver) throw new Error("driver session not found");
314
+ if ((driver.ownerId || null) !== ownerId) throw new Error("driver session access denied");
315
+ if (store.groupForMember(driver.localId)) throw new Error("this session is already in a split group");
316
+ groupName = undefined; // auto name from member titles
317
+ } else {
318
+ var driverVendor = validateVendor(driverSpec.vendor || "claude");
319
+ var driverEffort = yoke.clampEffort(driverVendor, driverSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[driverVendor]) || sm.currentEffort || "medium") || null;
320
+ driver = sm.createSessionRaw({ ownerId: ownerId, vendor: driverVendor, model: driverSpec.model || null, effort: driverEffort });
321
+ driver.title = "Driver · " + ((yoke.getVendorInfo(driverVendor) || {}).displayName || driverVendor);
322
+ if (driverSpec.effort) driver.loopSettings = { effort: driverSpec.effort };
323
+ groupName = "Agent pair";
324
+ }
325
+ var workerEffort = yoke.clampEffort(workerVendor, workerSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[workerVendor]) || sm.currentEffort || "medium") || null;
326
+ var worker = sm.createSessionRaw({ ownerId: ownerId, vendor: workerVendor, model: workerSpec.model || null, effort: workerEffort });
327
+ worker.title = "Worker · " + ((yoke.getVendorInfo(workerVendor) || {}).displayName || workerVendor);
328
+ if (workerSpec.effort) worker.loopSettings = { effort: workerSpec.effort };
329
+ var result = store.create(ws, {
330
+ members: [driver.localId, worker.localId],
331
+ pair: { driverId: driver.localId, workerId: worker.localId },
332
+ name: groupName,
333
+ });
334
+ if (!result.ok) throw new Error(result.error);
335
+ sm.broadcastSessionList();
336
+ return { driver: driver, worker: worker, group: result.group };
337
+ }
338
+
238
339
  function createPair(ws, msg) {
239
340
  try {
240
- if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
241
- var driverSpec = msg.driver || {};
242
- var workerSpec = msg.worker || {};
243
- var workerVendor = validateVendor(workerSpec.vendor || sm.lastVendor || "codex");
244
- var ownerId = ws._clayUser && ctx.usersModule.isMultiUser() ? ws._clayUser.id : null;
245
- var driver;
246
- var groupName;
247
- if (Number.isInteger(driverSpec.sessionId)) {
248
- // "Add Worker" on an existing session: the current session becomes
249
- // the Driver with its full conversation context intact.
250
- driver = sm.sessions.get(driverSpec.sessionId);
251
- if (!driver) throw new Error("driver session not found");
252
- if ((driver.ownerId || null) !== ownerId) throw new Error("driver session access denied");
253
- if (store.groupForMember(driver.localId)) throw new Error("this session is already in a split group");
254
- groupName = undefined; // auto name from member titles
255
- } else {
256
- var driverVendor = validateVendor(driverSpec.vendor || "claude");
257
- var driverEffort = yoke.clampEffort(driverVendor, driverSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[driverVendor]) || sm.currentEffort || "medium") || null;
258
- driver = sm.createSessionRaw({ ownerId: ownerId, vendor: driverVendor, model: driverSpec.model || null, effort: driverEffort });
259
- driver.title = "Driver · " + ((yoke.getVendorInfo(driverVendor) || {}).displayName || driverVendor);
260
- if (driverSpec.effort) driver.loopSettings = { effort: driverSpec.effort };
261
- groupName = "Agent pair";
262
- }
263
- var workerEffort = yoke.clampEffort(workerVendor, workerSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[workerVendor]) || sm.currentEffort || "medium") || null;
264
- var worker = sm.createSessionRaw({ ownerId: ownerId, vendor: workerVendor, model: workerSpec.model || null, effort: workerEffort });
265
- worker.title = "Worker · " + ((yoke.getVendorInfo(workerVendor) || {}).displayName || workerVendor);
266
- if (workerSpec.effort) worker.loopSettings = { effort: workerSpec.effort };
267
- var result = store.create(ws, {
268
- members: [driver.localId, worker.localId],
269
- pair: { driverId: driver.localId, workerId: worker.localId },
270
- name: groupName,
271
- });
272
- if (!result.ok) throw new Error(result.error);
273
- sm.broadcastSessionList();
274
- ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: result.group });
341
+ var created = createPairRecord(ws, msg);
342
+ ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: created.group });
275
343
  } catch (e) {
276
344
  ctx.sendTo(ws, { type: "pair_session_created", ok: false, error: e.message || String(e) });
277
345
  }
278
346
  }
279
347
 
348
+ workerProposal = attachWorkerProposal({
349
+ sm: sm,
350
+ isMate: ctx.isMate,
351
+ splitStore: store,
352
+ getSdk: ctx.getSdk,
353
+ sendTo: ctx.sendTo,
354
+ usersModule: ctx.usersModule,
355
+ getLinuxUserForSession: ctx.getLinuxUserForSession,
356
+ onProcessingChanged: ctx.onProcessingChanged,
357
+ adapters: ctx.adapters,
358
+ createPairRecord: createPairRecord,
359
+ sendToPartner: sendToPartner,
360
+ });
361
+
280
362
  function handleMessage(ws, msg) {
363
+ if (workerProposal.handleMessage(ws, msg)) return true;
281
364
  if (msg.type === "pair_session_options") {
282
365
  if (ctx.isMate) return false;
283
366
  ctx.sendTo(ws, {
@@ -298,11 +381,14 @@ function attachSessionPair(ctx) {
298
381
 
299
382
  function getSystemPrompt(session) {
300
383
  var group = store.groupForMember(session.localId);
301
- if (!group || !group.pair || group.pair.driverId !== session.localId) return "";
302
- return "You are the Driver in a two-agent pair. The tools send_to_partner and read_partner are provided directly to you. Use send_to_partner to delegate concrete bounded work to the Worker, use read_partner to inspect its progress or recent results, then integrate and verify the final outcome. Do not search the project for implementations of these tools, and do not delegate work that must be performed sequentially in this same session.";
384
+ var pairPrompt = "";
385
+ if (group && group.pair && group.pair.driverId === session.localId) {
386
+ pairPrompt = "You are the Driver in a two-agent pair. The tools send_to_partner and read_partner are provided directly to you. Use send_to_partner to delegate concrete bounded work to the Worker. If a non-waiting delegation finishes later, Clay pushes the result back and resumes you automatically; use read_partner only when you need an interim status check. Integrate and verify the final outcome. Do not search the project for implementations of these tools, and do not delegate work that must be performed sequentially in this same session.";
387
+ }
388
+ return [pairPrompt, workerProposal.getSystemPrompt(session)].filter(Boolean).join("\n\n");
303
389
  }
304
390
 
305
- return { getToolDefs: getToolDefs, handleMessage: handleMessage, getSystemPrompt: getSystemPrompt };
391
+ return { getToolDefs: getToolDefs, handleMessage: handleMessage, handleTurnDone: handleTurnDone, getSystemPrompt: getSystemPrompt };
306
392
  }
307
393
 
308
394
  module.exports = {
@@ -0,0 +1,317 @@
1
+ var crypto = require("crypto");
2
+ var yoke = require("./yoke");
3
+ var buildShape = require("./session-spawn-mcp-server").buildShape;
4
+
5
+ var MAX_SUMMARY_CHARS = 600;
6
+ var MAX_PLAN_CHARS = 6000;
7
+ var MAX_TASK_CHARS = 30000;
8
+
9
+ function modelValue(entry) {
10
+ if (typeof entry === "string") return entry;
11
+ return entry && (entry.value || entry.id) || "";
12
+ }
13
+
14
+ function modelLabel(entry) {
15
+ if (typeof entry === "string") return entry;
16
+ return entry && (entry.displayName || entry.name || entry.value || entry.id) || "";
17
+ }
18
+
19
+ function isFableSession(session, modelsByVendor, fallbackModel) {
20
+ if (!session || (session.vendor || "claude") !== "claude") return false;
21
+ var effectiveModel = session.model || fallbackModel || "";
22
+ var selected = String(effectiveModel).toLowerCase();
23
+ if (selected.indexOf("fable") !== -1) return true;
24
+ var models = (modelsByVendor && modelsByVendor.claude) || [];
25
+ for (var i = 0; i < models.length; i++) {
26
+ if (modelValue(models[i]) !== effectiveModel) continue;
27
+ if (modelLabel(models[i]).toLowerCase().indexOf("fable") !== -1) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function toolResult(value) {
33
+ return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
34
+ }
35
+
36
+ function attachWorkerProposal(ctx) {
37
+ var sm = ctx.sm;
38
+ var store = ctx.splitStore;
39
+
40
+ function isEligible(session) {
41
+ if (ctx.isMate || !session || session.mode === "tui") return false;
42
+ if (store.groupForMember(session.localId)) return false;
43
+ return isFableSession(session, sm.modelsByVendor, sm.currentModel);
44
+ }
45
+
46
+ function safeModelsByVendor(installed) {
47
+ var result = {};
48
+ for (var i = 0; i < installed.length; i++) {
49
+ var vendor = installed[i];
50
+ var source = (sm.modelsByVendor && sm.modelsByVendor[vendor]) || [];
51
+ result[vendor] = source.map(function (entry) {
52
+ if (typeof entry === "string") return entry;
53
+ return {
54
+ value: modelValue(entry),
55
+ displayName: modelLabel(entry),
56
+ supportedEffortLevels: entry.supportedEffortLevels || null,
57
+ };
58
+ });
59
+ }
60
+ return result;
61
+ }
62
+
63
+ function proposalOptions() {
64
+ var installed = (sm.installedVendors || []).slice();
65
+ var capabilities = {};
66
+ for (var i = 0; i < installed.length; i++) {
67
+ var source = (sm.capabilitiesByVendor && sm.capabilitiesByVendor[installed[i]]) || {};
68
+ var vendorInfo = yoke.getVendorInfo(installed[i]) || {};
69
+ var supportsEffort = Array.isArray(vendorInfo.effortLevels) && vendorInfo.effortLevels.length > 0;
70
+ capabilities[installed[i]] = { effort: source.effort === undefined ? supportsEffort : source.effort !== false };
71
+ }
72
+ return {
73
+ installedVendors: installed,
74
+ modelsByVendor: safeModelsByVendor(installed),
75
+ capabilitiesByVendor: capabilities,
76
+ };
77
+ }
78
+
79
+ async function ensureModelCatalogs() {
80
+ var installed = sm.installedVendors || [];
81
+ sm.modelsByVendor = sm.modelsByVendor || {};
82
+ for (var i = 0; i < installed.length; i++) {
83
+ var vendor = installed[i];
84
+ if (sm.modelsByVendor[vendor] && sm.modelsByVendor[vendor].length > 0) continue;
85
+ var adapter = ctx.adapters && ctx.adapters[vendor];
86
+ if (!adapter || typeof adapter.supportedModels !== "function") continue;
87
+ try { sm.modelsByVendor[vendor] = await adapter.supportedModels(); }
88
+ catch (err) { console.warn("[worker-proposal] Could not load " + vendor + " models:", err.message || err); }
89
+ }
90
+ }
91
+
92
+ function modelIsAvailable(options, vendor, model) {
93
+ if (!model) return true;
94
+ var models = options.modelsByVendor[vendor] || [];
95
+ for (var i = 0; i < models.length; i++) {
96
+ if (modelValue(models[i]) === model) return true;
97
+ }
98
+ return models.length === 0;
99
+ }
100
+
101
+ function chooseRecommendation(args, session, options) {
102
+ var installed = options.installedVendors;
103
+ var vendor = args.recommendedVendor;
104
+ if (installed.indexOf(vendor) === -1) {
105
+ if (installed.indexOf("codex") !== -1) vendor = "codex";
106
+ else {
107
+ vendor = installed[0] || session.vendor || "claude";
108
+ for (var i = 0; i < installed.length; i++) {
109
+ if (installed[i] !== session.vendor) { vendor = installed[i]; break; }
110
+ }
111
+ }
112
+ }
113
+ var models = options.modelsByVendor[vendor] || [];
114
+ var model = modelIsAvailable(options, vendor, args.recommendedModel) ? (args.recommendedModel || "") : "";
115
+ var currentModel = session.model || sm.currentModel || "";
116
+ if (vendor === session.vendor && model === currentModel) model = "";
117
+ if (!model && models.length > 0) {
118
+ for (var j = 0; j < models.length; j++) {
119
+ if (vendor === session.vendor && modelValue(models[j]) === currentModel) continue;
120
+ if (modelLabel(models[j]).toLowerCase().indexOf("fable") !== -1) continue;
121
+ model = modelValue(models[j]);
122
+ break;
123
+ }
124
+ }
125
+ if (!model && models.length > 0 && vendor !== session.vendor) model = modelValue(models[0]);
126
+ var effort = yoke.clampEffort(vendor, args.recommendedEffort || "medium") || "";
127
+ return { vendor: vendor, model: model, effort: effort };
128
+ }
129
+
130
+ function findProposal(session, proposalId) {
131
+ var history = (session && session.history) || [];
132
+ for (var i = history.length - 1; i >= 0; i--) {
133
+ if (history[i] && history[i].type === "worker_proposal" && history[i].proposalId === proposalId) return history[i];
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function hasPendingProposal(session) {
139
+ var history = (session && session.history) || [];
140
+ for (var i = history.length - 1; i >= 0; i--) {
141
+ var item = history[i];
142
+ if (!item || item.type !== "worker_proposal") continue;
143
+ return item.status === "pending" || item.status === "starting" || item.status === "running";
144
+ }
145
+ return false;
146
+ }
147
+
148
+ function updateProposal(session, proposal, patch) {
149
+ Object.assign(proposal, patch, { updatedAt: Date.now() });
150
+ sm.saveSessionFile(session);
151
+ sm.sendToSession(session, Object.assign({
152
+ type: "worker_proposal_update",
153
+ proposalId: proposal.proposalId,
154
+ }, patch));
155
+ }
156
+
157
+ async function propose(args, session) {
158
+ if (!isEligible(session)) return toolResult({ error: "Worker suggestions are only available in an unpaired Fable session." });
159
+ if (hasPendingProposal(session)) return toolResult({ error: "A Worker suggestion is already awaiting a decision." });
160
+ var summary = typeof args.summary === "string" ? args.summary.trim() : "";
161
+ var plan = typeof args.plan === "string" ? args.plan.trim() : "";
162
+ var task = typeof args.message === "string" ? args.message.trim() : "";
163
+ if (!summary || !plan || !task) return toolResult({ error: "summary, plan, and message are required." });
164
+ if (task.length > MAX_TASK_CHARS) return toolResult({ error: "The Worker task is too long." });
165
+ await ensureModelCatalogs();
166
+ var options = proposalOptions();
167
+ if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Worker session." });
168
+ var recommendation = chooseRecommendation(args, session, options);
169
+ var proposal = {
170
+ type: "worker_proposal",
171
+ proposalId: "worker_" + crypto.randomUUID(),
172
+ summary: summary.slice(0, MAX_SUMMARY_CHARS),
173
+ plan: plan.slice(0, MAX_PLAN_CHARS),
174
+ message: task,
175
+ status: "pending",
176
+ recommendedVendor: recommendation.vendor,
177
+ recommendedModel: recommendation.model,
178
+ recommendedEffort: recommendation.effort,
179
+ options: options,
180
+ };
181
+ sm.sendAndRecord(session, proposal);
182
+ return toolResult({
183
+ status: "posted",
184
+ proposalId: proposal.proposalId,
185
+ instruction: "The Worker suggestion is visible in the chat. End this turn now and wait for the user's decision.",
186
+ });
187
+ }
188
+
189
+ function resumeDriver(session, text) {
190
+ var sdk = ctx.getSdk();
191
+ if (!sdk) return Promise.reject(new Error("SDK bridge is not ready"));
192
+ var message = { type: "user_message", text: text, _internal: true };
193
+ sm.sendAndRecord(session, message);
194
+ session.sentToolResults = {};
195
+ if (!session.isProcessing) {
196
+ session.isProcessing = true;
197
+ ctx.onProcessingChanged();
198
+ sm.sendToSession(session, { type: "status", status: "processing" });
199
+ }
200
+ if (sdk.pushMessage(session, text)) return Promise.resolve();
201
+ return Promise.resolve(sdk.startQuery(session, text, undefined, ctx.getLinuxUserForSession(session)));
202
+ }
203
+
204
+ function parsePartnerResult(result) {
205
+ if (!result || result.isError || !result.content || !result.content[0]) {
206
+ return { status: "error", error: "Worker execution failed." };
207
+ }
208
+ try { return JSON.parse(result.content[0].text); }
209
+ catch (e) { return { status: "error", error: result.content[0].text || "Worker execution failed." }; }
210
+ }
211
+
212
+ async function runWorker(session, proposal) {
213
+ var result = parsePartnerResult(await ctx.sendToPartner({ message: proposal.message, wait: true, timeoutSeconds: 900 }, session));
214
+ var status = result.status === "complete" ? "completed" : result.status;
215
+ updateProposal(session, proposal, {
216
+ status: status || "error",
217
+ resultPreview: result.response ? result.response.slice(0, 1200) : "",
218
+ error: result.error || null,
219
+ });
220
+ var followup;
221
+ if (result.status === "complete") {
222
+ followup = "[Worker execution completed]\nReview the Worker's result, verify the implementation, and integrate any remaining changes.\n\n" + (result.response || "The Worker completed without a text summary.");
223
+ } else if (result.status === "running") {
224
+ followup = "[Worker execution is still running]\nUse read_partner to inspect progress before completing the task.";
225
+ } else {
226
+ followup = "[Worker execution failed]\nInspect the failure and decide whether to fix it here or delegate a narrower follow-up.\n\n" + (result.error || result.response || "Unknown Worker error.");
227
+ }
228
+ await resumeDriver(session, followup);
229
+ }
230
+
231
+ function sessionForResponse(ws) {
232
+ var session = sm.sessions.get(ws._clayActiveSession);
233
+ if (!session) throw new Error("active Driver session not found");
234
+ if (ctx.usersModule.isMultiUser()) {
235
+ var userId = ws._clayUser && ws._clayUser.id;
236
+ if (!userId || session.ownerId !== userId) throw new Error("Driver session access denied");
237
+ }
238
+ return session;
239
+ }
240
+
241
+ async function respondToProposal(ws, msg) {
242
+ var session = sessionForResponse(ws);
243
+ var proposal = findProposal(session, msg.proposalId);
244
+ if (!proposal) throw new Error("Worker suggestion not found");
245
+ if (proposal.status !== "pending") throw new Error("Worker suggestion has already been resolved");
246
+ if (!msg.accepted) {
247
+ updateProposal(session, proposal, { status: "declined" });
248
+ await resumeDriver(session, "[Worker suggestion declined]\nContinue this task in the current session using the plan you already prepared.");
249
+ return { ok: true, status: "declined" };
250
+ }
251
+ var options = proposal.options || proposalOptions();
252
+ var vendor = msg.vendor || proposal.recommendedVendor;
253
+ var model = msg.model || "";
254
+ if (options.installedVendors.indexOf(vendor) === -1) throw new Error("Selected Worker vendor is not installed");
255
+ if (!modelIsAvailable(options, vendor, model)) throw new Error("Selected Worker model is unavailable");
256
+ var effort = yoke.clampEffort(vendor, msg.effort || proposal.recommendedEffort || "medium") || "";
257
+ updateProposal(session, proposal, { status: "starting", selectedVendor: vendor, selectedModel: model, selectedEffort: effort });
258
+ try {
259
+ var created = ctx.createPairRecord(ws, {
260
+ driver: { sessionId: session.localId },
261
+ worker: { vendor: vendor, model: model, effort: effort },
262
+ });
263
+ updateProposal(session, proposal, { status: "running", groupId: created.group.id, workerId: created.worker.localId });
264
+ ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: created.group });
265
+ runWorker(session, proposal).catch(function (err) {
266
+ updateProposal(session, proposal, { status: "error", error: err.message || String(err) });
267
+ resumeDriver(session, "[Worker execution failed]\n" + (err.message || String(err))).catch(function () {});
268
+ });
269
+ return { ok: true, status: "running", group: created.group };
270
+ } catch (err) {
271
+ updateProposal(session, proposal, { status: "pending", error: err.message || String(err) });
272
+ return { ok: false, status: "pending", error: err.message || String(err) };
273
+ }
274
+ }
275
+
276
+ function handleMessage(ws, msg) {
277
+ if (msg.type !== "worker_proposal_response") return false;
278
+ respondToProposal(ws, msg).catch(function (err) {
279
+ ctx.sendTo(ws, { type: "worker_proposal_update", proposalId: msg.proposalId, status: "pending", error: err.message || String(err) });
280
+ });
281
+ return true;
282
+ }
283
+
284
+ function getToolDefs(session) {
285
+ if (!isEligible(session)) return [];
286
+ return [{
287
+ name: "propose_worker",
288
+ description: "After making a concise plan for an implementation-heavy task, suggest that the user run the execution with another model in a visible Worker session. Use this only before substantial execution begins, never for small edits, explanations, or tasks the user asked you to keep in this session.",
289
+ inputSchema: buildShape({
290
+ summary: { type: "string", description: "One short sentence explaining why a Worker is useful for this task." },
291
+ plan: { type: "string", description: "A concise numbered implementation plan to show in the approval card." },
292
+ message: { type: "string", description: "Complete execution instructions for the Worker, including scope, constraints, validation, and expected result." },
293
+ recommendedVendor: { type: "string", description: "Optional installed vendor id for the Worker." },
294
+ recommendedModel: { type: "string", description: "Optional exact Worker model id. Omit when uncertain." },
295
+ recommendedEffort: { type: "string", description: "Optional reasoning effort: minimal, low, medium, high, xhigh, or max." },
296
+ }, ["summary", "plan", "message"]),
297
+ handler: function (args) { return propose(args || {}, session); },
298
+ }];
299
+ }
300
+
301
+ function getSystemPrompt(session) {
302
+ if (!isEligible(session)) return "";
303
+ return "You can introduce Clay's Driver/Worker workflow with propose_worker. For an implementation-heavy task that is likely to consume substantial execution time, first reason about the work and form a concise plan. Before editing files or running substantial execution, call propose_worker once with that plan and a complete Worker instruction, recommending another suitable execution model. Use it conservatively: do not suggest a Worker for questions, investigations, tiny fixes, or when the user explicitly wants you to execute here. After posting a proposal, end the turn and wait. If accepted, the Worker runs visibly and its result returns to you for review.";
304
+ }
305
+
306
+ return {
307
+ getToolDefs: getToolDefs,
308
+ getSystemPrompt: getSystemPrompt,
309
+ handleMessage: handleMessage,
310
+ respondToProposal: respondToProposal,
311
+ };
312
+ }
313
+
314
+ module.exports = {
315
+ attachWorkerProposal: attachWorkerProposal,
316
+ isFableSession: isFableSession,
317
+ };
package/lib/project.js CHANGED
@@ -541,6 +541,7 @@ function createProjectContext(opts) {
541
541
  usersModule: usersModule,
542
542
  getLinuxUserForSession: getLinuxUserForSession,
543
543
  onProcessingChanged: onProcessingChanged,
544
+ adapters: adapters,
544
545
  });
545
546
  var _sessionSpawn = attachSessionSpawn({
546
547
  cwd: cwd,
@@ -817,9 +818,10 @@ function createProjectContext(opts) {
817
818
  clayTls: serverTls,
818
819
  clayAuthToken: serverAuthToken,
819
820
  onProcessingChanged: onProcessingChanged,
820
- onTurnDone: isMate ? function (session, preview) {
821
- digestDmTurn(session, preview);
822
- } : null,
821
+ onTurnDone: function (session, preview) {
822
+ if (isMate) digestDmTurn(session, preview);
823
+ else _sessionPair.handleTurnDone(session);
824
+ },
823
825
  scheduleMessage: function (session, text, resetsAt) {
824
826
  scheduleMessage(session, text, resetsAt);
825
827
  },
@@ -83,7 +83,7 @@ body.pane-mode #input-area {
83
83
  gap: 6px;
84
84
  padding: 0 5px 0 9px;
85
85
  border-bottom: 1px solid var(--border-subtle);
86
- background: var(--sidebar-bg);
86
+ background: var(--bg);
87
87
  color: var(--text-secondary);
88
88
  flex-shrink: 0;
89
89
  }
@@ -194,7 +194,9 @@ button.split-pair-role:hover { filter: brightness(1.25); }
194
194
  display: none;
195
195
  align-items: center;
196
196
  gap: 6px;
197
- margin: 0 12px;
197
+ width: 100%;
198
+ max-width: var(--content-width);
199
+ margin: 0 auto;
198
200
  padding: 4px 10px;
199
201
  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
200
202
  border-bottom: 0;
@@ -205,6 +207,9 @@ button.split-pair-role:hover { filter: brightness(1.25); }
205
207
  line-height: 1.4;
206
208
  }
207
209
  .pane-delegation-notice.visible { display: flex; }
210
+ .pane-delegation-notice.visible + #input-wrapper #input-row {
211
+ border-radius: 0 0 8px 8px;
212
+ }
208
213
  .pane-delegation-notice::before {
209
214
  content: "";
210
215
  width: 6px;
@@ -0,0 +1,127 @@
1
+ /* Inline approval surface for Fable's Driver/Worker recommendation. */
2
+
3
+ .worker-proposal-card {
4
+ width: min(var(--content-width), calc(100% - 40px));
5
+ margin: 16px auto;
6
+ padding: 16px;
7
+ border: 1px solid color-mix(in srgb, var(--accent2) 30%, var(--border));
8
+ border-radius: 16px;
9
+ background:
10
+ radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--accent2) 10%, transparent), transparent 42%),
11
+ var(--bg-alt);
12
+ box-shadow: 0 12px 32px rgba(var(--shadow-rgb), 0.12);
13
+ }
14
+
15
+ .worker-proposal-header {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 10px;
19
+ }
20
+
21
+ .worker-proposal-mark {
22
+ width: 34px;
23
+ height: 34px;
24
+ display: grid;
25
+ place-items: center;
26
+ flex-shrink: 0;
27
+ border-radius: 11px;
28
+ background: color-mix(in srgb, var(--accent2) 14%, var(--bg));
29
+ color: var(--accent2);
30
+ }
31
+
32
+ .worker-proposal-mark .lucide { width: 17px; height: 17px; }
33
+ .worker-proposal-heading { min-width: 0; display: grid; gap: 1px; }
34
+ .worker-proposal-heading strong { color: var(--text); font-size: 14px; line-height: 1.25; }
35
+ .worker-proposal-kicker { color: var(--accent2); font-size: 8px; font-weight: 800; letter-spacing: 0.14em; }
36
+
37
+ .worker-proposal-status {
38
+ margin-left: auto;
39
+ padding: 3px 8px;
40
+ border-radius: 999px;
41
+ background: color-mix(in srgb, var(--accent2) 10%, transparent);
42
+ color: var(--accent2);
43
+ font-size: 9px;
44
+ font-weight: 700;
45
+ white-space: nowrap;
46
+ }
47
+
48
+ .worker-proposal-summary {
49
+ margin: 12px 0 10px;
50
+ color: var(--text-secondary);
51
+ font-size: 13px;
52
+ line-height: 1.5;
53
+ }
54
+
55
+ .worker-proposal-plan {
56
+ display: grid;
57
+ gap: 5px;
58
+ margin: 0 0 14px;
59
+ padding: 10px 12px 10px 32px;
60
+ border: 1px solid var(--border-subtle);
61
+ border-radius: 11px;
62
+ background: color-mix(in srgb, var(--bg) 72%, transparent);
63
+ color: var(--text-muted);
64
+ font-size: 11px;
65
+ line-height: 1.45;
66
+ }
67
+
68
+ .worker-proposal-plan li::marker { color: var(--accent2); font-weight: 700; }
69
+
70
+ .worker-proposal-config {
71
+ display: grid;
72
+ grid-template-columns: minmax(120px, 0.8fr) minmax(160px, 1.2fr) minmax(190px, auto);
73
+ gap: 9px;
74
+ align-items: end;
75
+ }
76
+
77
+ .worker-proposal-field { min-width: 0; display: grid; gap: 5px; }
78
+ .worker-proposal-field > span { color: var(--text-dimmer); font-size: 9px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; }
79
+ .worker-proposal-vendor-control { position: relative; display: flex; align-items: center; }
80
+ .worker-proposal-vendor-control img { position: absolute; left: 9px; width: 15px; height: 15px; border-radius: 4px; pointer-events: none; z-index: 1; }
81
+ .worker-proposal-vendor-control .worker-proposal-select { padding-left: 30px; }
82
+
83
+ .worker-proposal-select {
84
+ width: 100%;
85
+ height: 34px;
86
+ min-width: 0;
87
+ padding: 0 28px 0 10px;
88
+ border: 1px solid var(--border);
89
+ border-radius: 9px;
90
+ background: var(--input-bg);
91
+ color: var(--text);
92
+ font: inherit;
93
+ font-size: 11px;
94
+ outline: none;
95
+ }
96
+
97
+ .worker-proposal-select:focus { border-color: var(--accent2); }
98
+ .worker-proposal-effort-buttons { display: flex; height: 34px; padding: 3px; border: 1px solid var(--border); border-radius: 9px; background: var(--input-bg); }
99
+ .worker-proposal-effort-btn { flex: 1; min-width: 38px; border: 0; border-radius: 6px; background: transparent; color: var(--text-dimmer); font: inherit; font-size: 9px; font-weight: 650; cursor: pointer; }
100
+ .worker-proposal-effort-btn:hover { color: var(--text); }
101
+ .worker-proposal-effort-btn.active { background: color-mix(in srgb, var(--accent2) 18%, var(--bg)); color: var(--accent2); }
102
+
103
+ .worker-proposal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
104
+ .worker-proposal-action { min-height: 34px; padding: 0 13px; border-radius: 9px; font: inherit; font-size: 11px; font-weight: 700; cursor: pointer; }
105
+ .worker-proposal-action.secondary { border: 1px solid var(--border); background: transparent; color: var(--text-muted); }
106
+ .worker-proposal-action.primary { display: inline-flex; align-items: center; gap: 6px; border: 1px solid color-mix(in srgb, var(--accent2) 55%, var(--border)); background: color-mix(in srgb, var(--accent2) 16%, var(--bg)); color: var(--accent2); }
107
+ .worker-proposal-action.primary .lucide { width: 13px; height: 13px; }
108
+ .worker-proposal-action:not(:disabled):hover { filter: brightness(1.12); }
109
+ .worker-proposal-action:disabled, .worker-proposal-select:disabled, .worker-proposal-effort-btn:disabled { cursor: default; opacity: 0.58; }
110
+
111
+ .worker-proposal-error, .worker-proposal-result { margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 10px; line-height: 1.45; }
112
+ .worker-proposal-error { color: var(--error); background: var(--error-8); }
113
+ .worker-proposal-result { max-height: 96px; overflow: hidden; color: var(--text-muted); background: color-mix(in srgb, var(--success) 7%, transparent); white-space: pre-wrap; }
114
+ .worker-proposal-card[data-status="starting"] .worker-proposal-status,
115
+ .worker-proposal-card[data-status="running"] .worker-proposal-status { animation: worker-proposal-pulse 1.6s ease-in-out infinite; }
116
+ .worker-proposal-card[data-status="completed"] { border-color: color-mix(in srgb, var(--success) 35%, var(--border)); }
117
+ .worker-proposal-card[data-status="completed"] .worker-proposal-status { color: var(--success); background: var(--success-8); }
118
+ .worker-proposal-card[data-status="declined"] .worker-proposal-status { color: var(--text-muted); background: rgba(var(--overlay-rgb), 0.04); }
119
+
120
+ @keyframes worker-proposal-pulse { 50% { opacity: 0.55; } }
121
+
122
+ @media (max-width: 720px) {
123
+ .worker-proposal-card { width: calc(100% - 24px); padding: 14px; }
124
+ .worker-proposal-config { grid-template-columns: 1fr 1fr; }
125
+ .worker-proposal-effort { grid-column: 1 / -1; }
126
+ .worker-proposal-status { display: none; }
127
+ }
@@ -21,6 +21,7 @@ import { handlePaletteSessionSwitch, setPaletteVersion } from './command-palette
21
21
  import { handleFindInSessionResults } from './session-search.js';
22
22
  import { syncPaneTitles, maybeRestoreSplitGroup, openGroup } from './split-view.js';
23
23
  import { showPairDialog, handlePairCreated, handleSplitDelegation, showWorkerDelegationNotice, hideWorkerDelegationNotice } from './split-pair-ui.js';
24
+ import { renderWorkerProposal, updateWorkerProposal } from './worker-proposal.js';
24
25
  import { handleInputSync, autoResize, builtinCommands, setScheduleBtnDisabled } from './input.js';
25
26
  import { startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, closeToolGroup, removeToolFromGroup, resetToolState, getTools, getPlanContent, setPlanContent, renderPlanBanner, renderPlanCard, getTodoTools, handleTodoWrite, handleTaskCreate, handleTaskUpdate, applyDeadSessionTodoCompaction, isPlanFilePath, enableMainInput, addTurnMeta, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, initSubagentStop, updateSubagentProgress, updateSubagentTaskStatus, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionCancelled, markPermissionResolved, renderElicitationRequest, markElicitationResolved, renderUserDialogRequest, markUserDialogResolved, updateThinkingTokens } from './tools.js';
26
27
  import { showDoneNotification, playDoneSound, isNotifAlertEnabled, isNotifSoundEnabled } from './notifications.js';
@@ -587,6 +588,14 @@ export function processMessage(msg) {
587
588
  if (createdPairGroup) openGroup(createdPairGroup);
588
589
  break;
589
590
 
591
+ case "worker_proposal":
592
+ renderWorkerProposal(msg);
593
+ break;
594
+
595
+ case "worker_proposal_update":
596
+ updateWorkerProposal(msg);
597
+ break;
598
+
590
599
  case "split_delegation":
591
600
  handleSplitDelegation(msg);
592
601
  syncPaneTitles();
@@ -932,7 +941,7 @@ export function processMessage(msg) {
932
941
  }
933
942
  renderPlanBanner("exit");
934
943
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
935
- } else if (msg.name === "propose_debate" || (msg.name && msg.name.indexOf("propose_debate") !== -1)) {
944
+ } else if (msg.name === "propose_debate" || (msg.name && msg.name.indexOf("propose_debate") !== -1) || (msg.name && msg.name.indexOf("propose_worker") !== -1)) {
936
945
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
937
946
  } else if (msg.name === "ask_user_questions") {
938
947
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
@@ -260,11 +260,12 @@ var workerNoticeEl = null;
260
260
  export function showWorkerDelegationNotice(driverTitle) {
261
261
  if (!store.get('paneMode')) return;
262
262
  var inputArea = document.getElementById("input-area");
263
- if (!inputArea || !inputArea.parentNode) return;
263
+ var inputWrapper = document.getElementById("input-wrapper");
264
+ if (!inputArea || !inputWrapper) return;
264
265
  if (!workerNoticeEl) {
265
266
  workerNoticeEl = document.createElement("div");
266
267
  workerNoticeEl.className = "pane-delegation-notice";
267
- inputArea.parentNode.insertBefore(workerNoticeEl, inputArea);
268
+ inputArea.insertBefore(workerNoticeEl, inputWrapper);
268
269
  }
269
270
  var who = driverTitle ? "“" + driverTitle + "”" : "the Driver";
270
271
  workerNoticeEl.textContent = "Following an instruction from " + who + " — messages you send now join the same turn.";
@@ -0,0 +1,239 @@
1
+ import { getWs } from './ws-ref.js';
2
+ import { addToMessages, scrollToBottom, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
3
+ import { effortLevelsFor, effortDisplayName } from './app-panels.js';
4
+ import { iconHtml, refreshIcons } from './icons.js';
5
+
6
+ function optionValue(entry) {
7
+ if (typeof entry === "string") return entry;
8
+ return entry && (entry.value || entry.id) || "";
9
+ }
10
+
11
+ function optionLabel(entry) {
12
+ if (typeof entry === "string") return entry;
13
+ return entry && (entry.displayName || entry.name || entry.value || entry.id) || "";
14
+ }
15
+
16
+ function findCard(proposalId) {
17
+ var cards = document.querySelectorAll(".worker-proposal-card");
18
+ for (var i = 0; i < cards.length; i++) {
19
+ if (cards[i].dataset.proposalId === proposalId) return cards[i];
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function planSteps(plan) {
25
+ return String(plan || "").split("\n").map(function (line) {
26
+ return line.trim().replace(/^\s*(?:\d+[.)]|[-*])\s*/, "");
27
+ }).filter(Boolean).slice(0, 8);
28
+ }
29
+
30
+ function buildVendorSelect(msg) {
31
+ var select = document.createElement("select");
32
+ select.className = "worker-proposal-select worker-proposal-vendor";
33
+ var installed = (msg.options && msg.options.installedVendors) || [];
34
+ for (var i = 0; i < installed.length; i++) {
35
+ var option = document.createElement("option");
36
+ option.value = installed[i];
37
+ option.textContent = VENDOR_NAMES[installed[i]] || installed[i];
38
+ select.appendChild(option);
39
+ }
40
+ if (installed.indexOf(msg.recommendedVendor) !== -1) select.value = msg.recommendedVendor;
41
+ return select;
42
+ }
43
+
44
+ function fillModels(select, vendor, msg, preferred) {
45
+ var models = (msg.options && msg.options.modelsByVendor && msg.options.modelsByVendor[vendor]) || [];
46
+ select.innerHTML = "";
47
+ var automatic = document.createElement("option");
48
+ automatic.value = "";
49
+ automatic.textContent = "Automatic";
50
+ select.appendChild(automatic);
51
+ for (var i = 0; i < models.length; i++) {
52
+ var option = document.createElement("option");
53
+ option.value = optionValue(models[i]);
54
+ option.textContent = optionLabel(models[i]);
55
+ select.appendChild(option);
56
+ }
57
+ select.value = preferred || "";
58
+ if (select.value !== (preferred || "")) select.value = "";
59
+ }
60
+
61
+ function fillEffort(card, vendor, model, msg, preferred) {
62
+ var wrap = card.querySelector(".worker-proposal-effort");
63
+ var buttons = card.querySelector(".worker-proposal-effort-buttons");
64
+ var capabilities = (msg.options && msg.options.capabilitiesByVendor && msg.options.capabilitiesByVendor[vendor]) || {};
65
+ if (capabilities.effort === false) {
66
+ wrap.classList.add("hidden");
67
+ buttons.innerHTML = "";
68
+ return;
69
+ }
70
+ wrap.classList.remove("hidden");
71
+ var models = (msg.options && msg.options.modelsByVendor && msg.options.modelsByVendor[vendor]) || [];
72
+ var levels = effortLevelsFor(vendor, models, model);
73
+ buttons.innerHTML = "";
74
+ var selected = levels.indexOf(preferred) !== -1 ? preferred : (levels.indexOf("medium") !== -1 ? "medium" : levels[0]);
75
+ for (var i = 0; i < levels.length; i++) {
76
+ var button = document.createElement("button");
77
+ button.type = "button";
78
+ button.className = "worker-proposal-effort-btn" + (levels[i] === selected ? " active" : "");
79
+ button.dataset.effort = levels[i];
80
+ button.textContent = effortDisplayName(levels[i]);
81
+ button.addEventListener("click", function () {
82
+ buttons.querySelectorAll("button").forEach(function (item) { item.classList.remove("active"); });
83
+ this.classList.add("active");
84
+ });
85
+ buttons.appendChild(button);
86
+ }
87
+ }
88
+
89
+ function setControlsDisabled(card, disabled) {
90
+ var controls = card.querySelectorAll("select, .worker-proposal-effort-btn, .worker-proposal-action");
91
+ for (var i = 0; i < controls.length; i++) controls[i].disabled = disabled;
92
+ }
93
+
94
+ function statusLabel(status) {
95
+ if (status === "starting") return "Starting Worker";
96
+ if (status === "running") return "Worker running";
97
+ if (status === "completed") return "Completed";
98
+ if (status === "declined") return "Continuing here";
99
+ if (status === "error") return "Needs attention";
100
+ return "Suggested by Fable";
101
+ }
102
+
103
+ function applyState(card, msg) {
104
+ var status = msg.status || "pending";
105
+ card.dataset.status = status;
106
+ var badge = card.querySelector(".worker-proposal-status");
107
+ if (badge) badge.textContent = statusLabel(status);
108
+ var error = card.querySelector(".worker-proposal-error");
109
+ if (error) {
110
+ error.textContent = msg.error || "";
111
+ error.classList.toggle("hidden", !msg.error);
112
+ }
113
+ var preview = card.querySelector(".worker-proposal-result");
114
+ if (preview && msg.resultPreview) {
115
+ preview.textContent = msg.resultPreview;
116
+ preview.classList.remove("hidden");
117
+ }
118
+ setControlsDisabled(card, status !== "pending");
119
+ }
120
+
121
+ function sendDecision(card, accepted) {
122
+ var ws = getWs();
123
+ if (!ws || ws.readyState !== 1 || card.dataset.status !== "pending") return;
124
+ var vendor = card.querySelector(".worker-proposal-vendor");
125
+ var model = card.querySelector(".worker-proposal-model");
126
+ var effort = card.querySelector(".worker-proposal-effort-btn.active");
127
+ applyState(card, { status: accepted ? "starting" : "declined" });
128
+ ws.send(JSON.stringify({
129
+ type: "worker_proposal_response",
130
+ proposalId: card.dataset.proposalId,
131
+ accepted: accepted,
132
+ vendor: vendor ? vendor.value : "",
133
+ model: model ? model.value : "",
134
+ effort: effort ? effort.dataset.effort : "",
135
+ }));
136
+ }
137
+
138
+ export function renderWorkerProposal(msg) {
139
+ var existing = findCard(msg.proposalId);
140
+ if (existing) {
141
+ applyState(existing, msg);
142
+ return;
143
+ }
144
+ var card = document.createElement("section");
145
+ card.className = "worker-proposal-card";
146
+ card.dataset.proposalId = msg.proposalId;
147
+ card.dataset.status = msg.status || "pending";
148
+
149
+ var header = document.createElement("div");
150
+ header.className = "worker-proposal-header";
151
+ header.innerHTML = '<div class="worker-proposal-mark">' + iconHtml("git-branch") + '</div><div class="worker-proposal-heading"><span class="worker-proposal-kicker">DRIVER / WORKER</span><strong>Let a Worker handle the execution</strong></div><span class="worker-proposal-status"></span>';
152
+ card.appendChild(header);
153
+
154
+ var summary = document.createElement("p");
155
+ summary.className = "worker-proposal-summary";
156
+ summary.textContent = msg.summary || "This task can be planned here and executed in a visible Worker session.";
157
+ card.appendChild(summary);
158
+
159
+ var steps = planSteps(msg.plan);
160
+ if (steps.length > 0) {
161
+ var plan = document.createElement("ol");
162
+ plan.className = "worker-proposal-plan";
163
+ for (var i = 0; i < steps.length; i++) {
164
+ var item = document.createElement("li");
165
+ item.textContent = steps[i];
166
+ plan.appendChild(item);
167
+ }
168
+ card.appendChild(plan);
169
+ }
170
+
171
+ var config = document.createElement("div");
172
+ config.className = "worker-proposal-config";
173
+ var vendorField = document.createElement("label");
174
+ vendorField.className = "worker-proposal-field worker-proposal-vendor-field";
175
+ vendorField.innerHTML = '<span>Worker</span><span class="worker-proposal-vendor-control"><img alt=""></span>';
176
+ var vendorSelect = buildVendorSelect(msg);
177
+ vendorField.querySelector(".worker-proposal-vendor-control").appendChild(vendorSelect);
178
+ config.appendChild(vendorField);
179
+ var modelField = document.createElement("label");
180
+ modelField.className = "worker-proposal-field";
181
+ modelField.innerHTML = "<span>Model</span>";
182
+ var modelSelect = document.createElement("select");
183
+ modelSelect.className = "worker-proposal-select worker-proposal-model";
184
+ modelField.appendChild(modelSelect);
185
+ config.appendChild(modelField);
186
+ var effortField = document.createElement("div");
187
+ effortField.className = "worker-proposal-field worker-proposal-effort";
188
+ effortField.innerHTML = '<span>Reasoning</span><div class="worker-proposal-effort-buttons"></div>';
189
+ config.appendChild(effortField);
190
+ card.appendChild(config);
191
+
192
+ function syncVendor(preferredModel, preferredEffort) {
193
+ var vendor = vendorSelect.value;
194
+ var avatar = vendorField.querySelector("img");
195
+ avatar.src = VENDOR_AVATARS[vendor] || VENDOR_AVATARS.claude;
196
+ fillModels(modelSelect, vendor, msg, preferredModel);
197
+ fillEffort(card, vendor, modelSelect.value, msg, preferredEffort);
198
+ }
199
+ vendorSelect.addEventListener("change", function () { syncVendor("", "medium"); });
200
+ modelSelect.addEventListener("change", function () {
201
+ var active = card.querySelector(".worker-proposal-effort-btn.active");
202
+ fillEffort(card, vendorSelect.value, modelSelect.value, msg, active ? active.dataset.effort : "medium");
203
+ });
204
+ syncVendor(msg.recommendedModel || "", msg.recommendedEffort || "medium");
205
+
206
+ var error = document.createElement("div");
207
+ error.className = "worker-proposal-error hidden";
208
+ card.appendChild(error);
209
+ var result = document.createElement("div");
210
+ result.className = "worker-proposal-result hidden";
211
+ card.appendChild(result);
212
+
213
+ var actions = document.createElement("div");
214
+ actions.className = "worker-proposal-actions";
215
+ var decline = document.createElement("button");
216
+ decline.type = "button";
217
+ decline.className = "worker-proposal-action secondary";
218
+ decline.textContent = "Continue here";
219
+ decline.addEventListener("click", function () { sendDecision(card, false); });
220
+ var accept = document.createElement("button");
221
+ accept.type = "button";
222
+ accept.className = "worker-proposal-action primary";
223
+ accept.innerHTML = iconHtml("panel-right-open") + "<span>Run with Worker</span>";
224
+ accept.addEventListener("click", function () { sendDecision(card, true); });
225
+ actions.appendChild(decline);
226
+ actions.appendChild(accept);
227
+ card.appendChild(actions);
228
+
229
+ addToMessages(card);
230
+ applyState(card, msg);
231
+ refreshIcons();
232
+ scrollToBottom();
233
+ }
234
+
235
+ export function updateWorkerProposal(msg) {
236
+ var card = findCard(msg.proposalId);
237
+ if (!card) return;
238
+ applyState(card, msg);
239
+ }
@@ -2,6 +2,7 @@
2
2
  @import url("css/icon-strip.css");
3
3
  @import url("css/title-bar.css");
4
4
  @import url("css/pane.css");
5
+ @import url("css/worker-proposal.css");
5
6
  @import url("css/sidebar.css");
6
7
  @import url("css/overlays.css");
7
8
  @import url("css/menus.css");
package/lib/sdk-bridge.js CHANGED
@@ -559,7 +559,7 @@ function createSDKBridge(opts) {
559
559
  // split partner, and a prompt on every delegation hop defeats the
560
560
  // driver/worker workflow. spawn_sessions is NOT here: it creates new
561
561
  // sessions and keeps its prompt.
562
- var pairPartnerTools = { send_to_partner: true, read_partner: true };
562
+ var pairPartnerTools = { send_to_partner: true, read_partner: true, propose_worker: true };
563
563
  if (toolName.indexOf("mcp__clay-sessions__") === 0) {
564
564
  var sessionsToolName = toolName.substring(toolName.lastIndexOf("__") + 2);
565
565
  if (pairPartnerTools[sessionsToolName]) {
@@ -6,7 +6,7 @@ function getToolDefs(handlers) {
6
6
  return [
7
7
  {
8
8
  name: "send_to_partner",
9
- description: "Delegate one concrete task to the other session in this split. The partner works visibly in its own pane. A delegated turn cannot delegate back, so keep orchestration one hop deep.",
9
+ description: "Delegate one concrete task to the other session in this split. The partner works visibly in its own pane. Detached completions are pushed back automatically. A delegated turn cannot delegate back, so keep orchestration one hop deep.",
10
10
  inputSchema: buildShape({
11
11
  message: { type: "string", description: "The complete task or question for the partner." },
12
12
  wait: { type: "boolean", description: "Wait for the partner's turn to finish. Defaults to true." },
@@ -16,7 +16,7 @@ function getToolDefs(handlers) {
16
16
  },
17
17
  {
18
18
  name: "read_partner",
19
- description: "Read the partner's current status and recent conversation turns. Use this after a non-waiting delegation or timeout.",
19
+ description: "Read the partner's current status and recent conversation turns. Use this for an interim check after a non-waiting delegation or timeout; completed results are pushed automatically.",
20
20
  inputSchema: buildShape({
21
21
  lastTurns: { type: "number", description: "Number of recent user-message-delimited turns, from 1 to 5. Defaults to 1." },
22
22
  }),
package/lib/ws-schema.js CHANGED
@@ -38,6 +38,7 @@ var schema = {
38
38
  "split_group_set_pair": { direction: "c2s", handler: "lib/session-split-groups.js", description: "Assign Driver/Worker roles on a split group (driverId null clears them)" },
39
39
  "pair_session_options": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Request vendors/models for the pair-session dialog (response reuses the same type)" },
40
40
  "pair_session_create": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Create a Driver/Worker session pair as a split group" },
41
+ "worker_proposal_response": { direction: "c2s", handler: "lib/project-worker-proposal.js", description: "Accept or decline a Fable Worker suggestion with the selected runtime" },
41
42
 
42
43
  "session_list": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full list of sessions for the sidebar" },
43
44
  "session_switched": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Confirmation that active session changed" },
@@ -53,6 +54,8 @@ var schema = {
53
54
  "split_groups": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full persistent split-group list for the current user" },
54
55
  "split_group_result": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Result of a split-group mutation" },
55
56
  "pair_session_created": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Result of pair_session_create; carries the new group" },
57
+ "worker_proposal": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Inline Fable suggestion to delegate execution to a Worker" },
58
+ "worker_proposal_update": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Worker suggestion lifecycle update" },
56
59
  "split_delegation": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A pair delegation started or ended (drives the split-view flow indicator)" },
57
60
 
58
61
  // -----------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "3.2.2-beta.2",
3
+ "version": "3.3.0-beta.1",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",