clay-server 3.2.2-beta.3 → 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.
- package/lib/project-session-pair.js +129 -43
- package/lib/project-worker-proposal.js +317 -0
- package/lib/project.js +5 -3
- package/lib/public/css/worker-proposal.css +127 -0
- package/lib/public/modules/app-messages.js +10 -1
- package/lib/public/modules/worker-proposal.js +239 -0
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +1 -1
- package/lib/session-pair-mcp-server.js +2 -2
- package/lib/ws-schema.js +3 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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:
|
|
821
|
-
digestDmTurn(session, preview);
|
|
822
|
-
|
|
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
|
},
|
|
@@ -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 };
|
|
@@ -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
|
+
}
|
package/lib/public/style.css
CHANGED
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