clay-server 4.0.0-beta.13 → 4.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/lib/capsule-display-floor.js +51 -0
  2. package/lib/capsule-frame-server.js +229 -0
  3. package/lib/capsule-pig-logic.js +268 -0
  4. package/lib/capsule-server-runtimes.js +41 -0
  5. package/lib/capsule-tictactoe-logic.js +260 -0
  6. package/lib/capsules/pig/display.js +190 -0
  7. package/lib/capsules/pig/manifest.json +9 -0
  8. package/lib/capsules/pig/ui.json +46 -0
  9. package/lib/capsules/tictactoe/manifest.json +9 -0
  10. package/lib/capsules/tictactoe/ui.json +203 -0
  11. package/lib/project-capsule-catalog.js +9 -1
  12. package/lib/project-connection.js +1 -1
  13. package/lib/project-log-feedback-delivery.js +76 -0
  14. package/lib/project-logs.js +4 -0
  15. package/lib/project-pair-lifecycle.js +7 -12
  16. package/lib/project-session-pair.js +29 -39
  17. package/lib/project-worker-proposal.js +203 -72
  18. package/lib/project.js +13 -0
  19. package/lib/public/css/capsule-ui.css +18 -0
  20. package/lib/public/css/home-session-actions.css +66 -0
  21. package/lib/public/css/home-sidebar.css +66 -0
  22. package/lib/public/css/mobile-nav.css +80 -0
  23. package/lib/public/css/sidebar.css +92 -0
  24. package/lib/public/css/worker-proposal.css +34 -1
  25. package/lib/public/modules/app-messages.js +14 -1
  26. package/lib/public/modules/home-conversations-sheet.js +102 -48
  27. package/lib/public/modules/home-session-actions.js +6 -0
  28. package/lib/public/modules/home-sidebar-chat-list.js +101 -41
  29. package/lib/public/modules/home-tool-frame.js +165 -0
  30. package/lib/public/modules/home-tools.js +129 -1
  31. package/lib/public/modules/session-hierarchy.js +54 -0
  32. package/lib/public/modules/sidebar-mobile.js +35 -6
  33. package/lib/public/modules/sidebar-session-hierarchy.js +206 -0
  34. package/lib/public/modules/sidebar-sessions.js +49 -7
  35. package/lib/public/modules/worker-proposal-state.js +16 -0
  36. package/lib/public/modules/worker-proposal.js +49 -12
  37. package/lib/sdk-bridge.js +16 -8
  38. package/lib/sdk-message-processor.js +34 -4
  39. package/lib/server-home-chat.js +12 -1
  40. package/lib/server-tools.js +97 -9
  41. package/lib/server.js +3 -0
  42. package/lib/session-driver-eligibility.js +20 -165
  43. package/lib/session-pair-factory.js +31 -28
  44. package/lib/session-pair-mcp-server.js +5 -9
  45. package/lib/session-pair-prompts.js +15 -15
  46. package/lib/session-provenance.js +119 -0
  47. package/lib/session-spawn-mcp-server.js +1 -1
  48. package/lib/sessions.js +40 -2
  49. package/lib/tools-registry.js +44 -10
  50. package/lib/ws-schema.js +8 -3
  51. package/lib/yoke/adapters/claude.js +12 -0
  52. package/package.json +1 -1
@@ -1,48 +1,35 @@
1
1
  var crypto = require("crypto");
2
2
  var yoke = require("./yoke");
3
3
  var buildShape = require("./session-spawn-mcp-server").buildShape;
4
-
4
+ var driverEligibility = require("./session-driver-eligibility");
5
5
  var MAX_SUMMARY_CHARS = 600;
6
6
  var MAX_PLAN_CHARS = 6000;
7
7
  var MAX_TASK_CHARS = 30000;
8
-
8
+ var MAX_RATIONALE_CHARS = 1000;
9
9
  function modelValue(entry) {
10
10
  if (typeof entry === "string") return entry;
11
11
  return entry && (entry.value || entry.id) || "";
12
12
  }
13
-
14
13
  function modelLabel(entry) {
15
14
  if (typeof entry === "string") return entry;
16
15
  return entry && (entry.displayName || entry.name || entry.value || entry.id) || "";
17
16
  }
18
17
 
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
18
  function toolResult(value) {
33
19
  return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
34
20
  }
35
-
36
21
  function attachWorkerProposal(ctx) {
37
22
  var sm = ctx.sm;
38
23
  var store = ctx.splitStore;
24
+ function isLiveSession(session) {
25
+ return !!session && sm.sessions.get(session.localId) === session;
26
+ }
39
27
 
40
28
  function isEligible(session) {
41
- if (ctx.isMate || !session || session.mode === "tui") return false;
29
+ if (ctx.isMate || !isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm)) return false;
42
30
  if (store.groupForMember(session.localId)) return false;
43
- return isFableSession(session, sm.modelsByVendor, (sm.defaultModelByVendor || {})[session.vendor || "claude"]);
31
+ return true;
44
32
  }
45
-
46
33
  function safeModelsByVendor(installed) {
47
34
  var result = {};
48
35
  for (var i = 0; i < installed.length; i++) {
@@ -95,7 +82,21 @@ function attachWorkerProposal(ctx) {
95
82
  for (var i = 0; i < models.length; i++) {
96
83
  if (modelValue(models[i]) === model) return true;
97
84
  }
98
- return models.length === 0;
85
+ return false;
86
+ }
87
+
88
+ function effortIsAvailable(options, vendor, model, effort) {
89
+ var capabilities = options.capabilitiesByVendor[vendor] || {};
90
+ if (capabilities.effort === false) return !effort;
91
+ var clamped = yoke.clampEffort(vendor, effort) || "";
92
+ if (!effort || clamped !== effort) return false;
93
+ var models = options.modelsByVendor[vendor] || [];
94
+ for (var i = 0; i < models.length; i++) {
95
+ if (modelValue(models[i]) !== model) continue;
96
+ var levels = models[i] && models[i].supportedEffortLevels;
97
+ return !Array.isArray(levels) || levels.length === 0 || levels.indexOf(effort) !== -1;
98
+ }
99
+ return true;
99
100
  }
100
101
 
101
102
  function chooseRecommendation(args, session, options) {
@@ -117,7 +118,6 @@ function attachWorkerProposal(ctx) {
117
118
  if (!model && models.length > 0) {
118
119
  for (var j = 0; j < models.length; j++) {
119
120
  if (vendor === session.vendor && modelValue(models[j]) === currentModel) continue;
120
- if (modelLabel(models[j]).toLowerCase().indexOf("fable") !== -1) continue;
121
121
  model = modelValue(models[j]);
122
122
  break;
123
123
  }
@@ -127,6 +127,31 @@ function attachWorkerProposal(ctx) {
127
127
  return { vendor: vendor, model: model, effort: effort };
128
128
  }
129
129
 
130
+ function skipPermissionsEnabled(session) {
131
+ return !!(session && (session.permissionMode === "bypassPermissions" ||
132
+ session.dangerouslySkipPermissions || ctx.dangerouslySkipPermissions));
133
+ }
134
+
135
+ function recommendationCanAutoAccept(args, recommendation, options) {
136
+ var requestedVendor = typeof args.recommendedVendor === "string" ? args.recommendedVendor.trim() : "";
137
+ var requestedModel = typeof args.recommendedModel === "string" ? args.recommendedModel.trim() : "";
138
+ var requestedEffort = typeof args.recommendedEffort === "string" ? args.recommendedEffort.trim() : "";
139
+ if (!requestedVendor || requestedVendor !== recommendation.vendor) return false;
140
+ if (!requestedModel || requestedModel !== recommendation.model ||
141
+ !modelIsAvailable(options, requestedVendor, requestedModel)) return false;
142
+ var capabilities = options.capabilitiesByVendor[requestedVendor] || {};
143
+ if (capabilities.effort === false) return !requestedEffort && !recommendation.effort;
144
+ return requestedEffort === recommendation.effort &&
145
+ effortIsAvailable(options, requestedVendor, requestedModel, requestedEffort);
146
+ }
147
+
148
+ function autoAcceptanceWs(session) {
149
+ return {
150
+ _clayActiveSession: session.localId,
151
+ _clayUser: session.ownerId ? { id: session.ownerId } : null,
152
+ };
153
+ }
154
+
130
155
  function findProposal(session, proposalId) {
131
156
  var history = (session && session.history) || [];
132
157
  for (var i = history.length - 1; i >= 0; i--) {
@@ -154,31 +179,24 @@ function attachWorkerProposal(ctx) {
154
179
  }, patch));
155
180
  }
156
181
 
157
- function skipPermissionsEnabled(session) {
158
- return !!session && (session.permissionMode === "bypassPermissions" || session.dangerouslySkipPermissions === true);
159
- }
160
-
161
- function autoApprovalWs(session) {
162
- return {
163
- _clayActiveSession: session.localId,
164
- _clayUser: session.ownerId ? { id: session.ownerId } : null,
165
- _autoApproval: true,
166
- };
167
- }
168
-
169
182
  async function propose(args, session) {
170
- if (!isEligible(session)) return toolResult({ error: "Split Worker suggestions are only available in an unpaired Fable session." });
183
+ if (!isEligible(session)) return toolResult({ error: "Split Worker proposals are only available in an eligible unpaired Driver session." });
171
184
  if (hasPendingProposal(session)) return toolResult({ error: "A Split Worker suggestion is already awaiting a decision." });
172
185
  var summary = typeof args.summary === "string" ? args.summary.trim() : "";
173
186
  var plan = typeof args.plan === "string" ? args.plan.trim() : "";
174
187
  var task = typeof args.message === "string" ? args.message.trim() : "";
175
- if (!summary || !plan || !task) return toolResult({ error: "summary, plan, and message are required." });
188
+ var rationale = typeof args.recommendationRationale === "string" ? args.recommendationRationale.trim() : "";
189
+ if (!summary || !plan || !task || !rationale) {
190
+ return toolResult({ error: "summary, plan, message, and recommendationRationale are required." });
191
+ }
176
192
  if (task.length > MAX_TASK_CHARS) return toolResult({ error: "The Split Worker task is too long." });
177
193
  await ensureModelCatalogs();
194
+ if (!isEligible(session)) {
195
+ return toolResult({ error: "The Driver session changed while Split Worker runtimes were loading." });
196
+ }
178
197
  var options = proposalOptions();
179
198
  if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Split Worker session." });
180
199
  var recommendation = chooseRecommendation(args, session, options);
181
- var autoApprove = skipPermissionsEnabled(session);
182
200
  var proposal = {
183
201
  type: "worker_proposal",
184
202
  proposalId: "worker_" + crypto.randomUUID(),
@@ -189,22 +207,22 @@ function attachWorkerProposal(ctx) {
189
207
  recommendedVendor: recommendation.vendor,
190
208
  recommendedModel: recommendation.model,
191
209
  recommendedEffort: recommendation.effort,
210
+ recommendationRationale: rationale.slice(0, MAX_RATIONALE_CHARS),
192
211
  options: options,
193
212
  };
194
- if (autoApprove) proposal.autoApproved = true;
195
213
  sm.sendAndRecord(session, proposal);
196
- if (autoApprove) {
214
+ if (skipPermissionsEnabled(session) && recommendationCanAutoAccept(args, recommendation, options)) {
197
215
  var accepted = await acceptProposal(session, proposal, {
198
216
  vendor: recommendation.vendor,
199
217
  model: recommendation.model,
200
218
  effort: recommendation.effort,
201
- autoApproved: true,
202
- }, autoApprovalWs(session));
203
- if (!accepted.ok) return toolResult({ error: accepted.error || "Could not start the Split Worker." });
219
+ }, autoAcceptanceWs(session), true);
204
220
  return toolResult({
205
- status: "running",
221
+ status: accepted.ok ? "auto_accepted" : "posted",
206
222
  proposalId: proposal.proposalId,
207
- instruction: "The Split Worker was auto-approved and started because skip permissions is enabled. Its result will return for review.",
223
+ instruction: accepted.ok
224
+ ? "The recorded Split Worker configuration was auto-accepted under the Driver session's full-access mode. End this turn now while the exact paired Worker runs."
225
+ : "The automatic decision failed closed. The configuration card remains pending for the user; end this turn and wait for their decision.",
208
226
  });
209
227
  }
210
228
  return toolResult({
@@ -214,6 +232,75 @@ function attachWorkerProposal(ctx) {
214
232
  });
215
233
  }
216
234
 
235
+ async function proposeReplacement(args, session) {
236
+ var group = session && store.groupForMember(session.localId);
237
+ if (ctx.isMate || !isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) || !group ||
238
+ !group.pair || group.pair.driverId !== session.localId) {
239
+ return toolResult({ error: "Split Worker replacement proposals require the exact paired Driver session." });
240
+ }
241
+ var sourceGroupId = group.id;
242
+ var sourceWorkerId = group.pair.workerId;
243
+ if (hasPendingProposal(session)) return toolResult({ error: "A Split Worker proposal is already awaiting a decision." });
244
+ var task = typeof args.message === "string" ? args.message.trim() : "";
245
+ var rationale = typeof args.recommendationRationale === "string" ? args.recommendationRationale.trim() : "";
246
+ if (!task) return toolResult({ error: "message is required so an accepted replacement delegates exactly once." });
247
+ if (!rationale) return toolResult({ error: "recommendationRationale is required for the replacement audit trail." });
248
+ if (task.length > MAX_TASK_CHARS) return toolResult({ error: "The Split Worker task is too long." });
249
+ await ensureModelCatalogs();
250
+ var liveGroup = store.groupForMember(session.localId);
251
+ if (!isLiveSession(session) || !driverEligibility.isEligibleDriverSession(session, sm) ||
252
+ !liveGroup || liveGroup.id !== sourceGroupId || !liveGroup.pair ||
253
+ liveGroup.pair.driverId !== session.localId || liveGroup.pair.workerId !== sourceWorkerId) {
254
+ return toolResult({ error: "The Driver/Split Worker pair changed while replacement runtimes were loading." });
255
+ }
256
+ var options = proposalOptions();
257
+ if (options.installedVendors.length === 0) return toolResult({ error: "No coding agent is installed for a Split Worker session." });
258
+ var recommendationArgs = {
259
+ recommendedVendor: args.workerVendor,
260
+ recommendedModel: args.workerModel,
261
+ recommendedEffort: args.workerEffort,
262
+ };
263
+ var recommendation = chooseRecommendation(recommendationArgs, session, options);
264
+ var proposal = {
265
+ type: "worker_proposal",
266
+ proposalId: "worker_" + crypto.randomUUID(),
267
+ action: "replace",
268
+ summary: "Replace the current Split Worker before the next delegated task.",
269
+ plan: "1. Preserve the current Worker's session and history\n2. Create the selected replacement runtime\n3. Delegate the next task exactly once",
270
+ message: task,
271
+ status: "pending",
272
+ recommendedVendor: recommendation.vendor,
273
+ recommendedModel: recommendation.model,
274
+ recommendedEffort: recommendation.effort,
275
+ recommendationRationale: rationale.slice(0, MAX_RATIONALE_CHARS),
276
+ options: options,
277
+ sourceGroupId: sourceGroupId,
278
+ sourceWorkerId: sourceWorkerId,
279
+ interrupt: args.interrupt === true,
280
+ evaluation: args.evaluation || null,
281
+ };
282
+ sm.sendAndRecord(session, proposal);
283
+ if (skipPermissionsEnabled(session) && recommendationCanAutoAccept(recommendationArgs, recommendation, options)) {
284
+ var accepted = await acceptProposal(session, proposal, {
285
+ vendor: recommendation.vendor,
286
+ model: recommendation.model,
287
+ effort: recommendation.effort,
288
+ }, autoAcceptanceWs(session), true);
289
+ return toolResult({
290
+ status: accepted.ok ? "auto_accepted" : "posted",
291
+ proposalId: proposal.proposalId,
292
+ instruction: accepted.ok
293
+ ? "The recorded replacement configuration was auto-accepted under the Driver session's full-access mode."
294
+ : "The automatic replacement failed closed. The card remains pending for the user.",
295
+ });
296
+ }
297
+ return toolResult({
298
+ status: "posted",
299
+ proposalId: proposal.proposalId,
300
+ instruction: "The replacement configuration is visible in the chat. End this turn now and wait for the user's decision.",
301
+ });
302
+ }
303
+
217
304
  function resumeDriver(session, text) {
218
305
  var sdk = ctx.getSdk();
219
306
  if (!sdk) return Promise.reject(new Error("SDK bridge is not ready"));
@@ -268,31 +355,86 @@ function attachWorkerProposal(ctx) {
268
355
  return session;
269
356
  }
270
357
 
271
- async function acceptProposal(session, proposal, msg, ws) {
272
- var options = proposal.options || proposalOptions();
358
+ async function acceptProposal(session, proposal, msg, ws, trustedAutoAcceptance) {
359
+ if (!isLiveSession(session)) throw new Error("The Driver session is no longer live");
360
+ if (proposal.action !== "replace" && !isEligible(session)) {
361
+ throw new Error("The Driver is no longer eligible to create this Split Worker");
362
+ }
363
+ var approvedGroup = null;
364
+ if (proposal.action === "replace") {
365
+ approvedGroup = store.groupForMember(session.localId);
366
+ if (!driverEligibility.isEligibleDriverSession(session, sm) || !approvedGroup ||
367
+ approvedGroup.id !== proposal.sourceGroupId || !approvedGroup.pair ||
368
+ approvedGroup.pair.driverId !== session.localId || approvedGroup.pair.workerId !== proposal.sourceWorkerId) {
369
+ throw new Error("The Driver/Split Worker pair changed before the replacement was approved");
370
+ }
371
+ }
372
+ var options = proposalOptions();
273
373
  var vendor = msg.vendor || proposal.recommendedVendor;
274
374
  var model = msg.model || "";
275
375
  if (options.installedVendors.indexOf(vendor) === -1) throw new Error("Selected Split Worker vendor is not installed");
276
376
  if (!modelIsAvailable(options, vendor, model)) throw new Error("Selected Split Worker model is unavailable");
277
- var effort = yoke.clampEffort(vendor, msg.effort || proposal.recommendedEffort || "medium") || "";
278
- var startingPatch = { status: "starting", selectedVendor: vendor, selectedModel: model, selectedEffort: effort };
279
- if (msg.autoApproved) startingPatch.autoApproved = true;
377
+ var requestedEffort = msg.effort || proposal.recommendedEffort || "medium";
378
+ var effort = yoke.clampEffort(vendor, requestedEffort) || "";
379
+ if ((msg.effort && effort !== msg.effort) || !effortIsAvailable(options, vendor, model, effort)) {
380
+ throw new Error("Selected Split Worker reasoning effort is unavailable");
381
+ }
382
+ var autoAccepted = trustedAutoAcceptance === true;
383
+ var startingPatch = {
384
+ status: "starting",
385
+ selectedVendor: vendor,
386
+ selectedModel: model,
387
+ selectedEffort: effort,
388
+ autoAccepted: autoAccepted,
389
+ decisionMode: autoAccepted ? "driver_recommendation" : "user",
390
+ decidedAt: Date.now(),
391
+ };
280
392
  updateProposal(session, proposal, startingPatch);
281
393
  try {
394
+ if (proposal.action === "replace") {
395
+ var liveGroup = approvedGroup;
396
+ var replaced = await ctx.replacePartner({
397
+ interrupt: proposal.interrupt === true,
398
+ workerVendor: vendor,
399
+ workerModel: model,
400
+ workerEffort: effort,
401
+ evaluation: proposal.evaluation || undefined,
402
+ }, session);
403
+ liveGroup = store.groupForMember(session.localId);
404
+ updateProposal(session, proposal, { status: "running", groupId: liveGroup.id, workerId: replaced.workerSessionId });
405
+ runWorker(session, proposal).catch(function (err) {
406
+ updateProposal(session, proposal, { status: "error", error: err.message || String(err) });
407
+ resumeDriver(session, "[Split Worker execution failed]\n" + (err.message || String(err))).catch(function () {});
408
+ });
409
+ return { ok: true, status: "running", group: liveGroup, replacement: replaced };
410
+ }
282
411
  var created = ctx.createPairRecord(ws, {
283
412
  driver: { sessionId: session.localId },
284
413
  worker: { vendor: vendor, model: model, effort: effort },
285
414
  });
415
+ ctx.recordGenerationStart(session, created.worker);
286
416
  updateProposal(session, proposal, { status: "running", groupId: created.group.id, workerId: created.worker.localId });
287
- if (ws._autoApproval) sm.sendToSession(session, { type: "pair_session_created", ok: true, group: created.group });
288
- else ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: created.group });
417
+ ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: created.group });
289
418
  runWorker(session, proposal).catch(function (err) {
290
419
  updateProposal(session, proposal, { status: "error", error: err.message || String(err) });
291
420
  resumeDriver(session, "[Split Worker execution failed]\n" + (err.message || String(err))).catch(function () {});
292
421
  });
293
422
  return { ok: true, status: "running", group: created.group };
294
423
  } catch (err) {
295
- updateProposal(session, proposal, { status: "pending", error: err.message || String(err) });
424
+ if (proposal.action === "replace") {
425
+ var restoredGroup = store.groupForMember(session.localId);
426
+ if (restoredGroup && restoredGroup.pair && restoredGroup.pair.driverId === session.localId &&
427
+ restoredGroup.pair.workerId === proposal.sourceWorkerId) {
428
+ proposal.sourceGroupId = restoredGroup.id;
429
+ }
430
+ }
431
+ updateProposal(session, proposal, {
432
+ status: "pending",
433
+ error: err.message || String(err),
434
+ autoAccepted: false,
435
+ decisionMode: null,
436
+ decidedAt: null,
437
+ });
296
438
  return { ok: false, status: "pending", error: err.message || String(err) };
297
439
  }
298
440
  }
@@ -304,7 +446,10 @@ function attachWorkerProposal(ctx) {
304
446
  if (proposal.status !== "pending") throw new Error("Split Worker suggestion has already been resolved");
305
447
  if (!msg.accepted) {
306
448
  updateProposal(session, proposal, { status: "declined" });
307
- await resumeDriver(session, "[Split Worker suggestion declined]\nContinue this task in the current session using the plan you already prepared.");
449
+ var declinedText = proposal.action === "replace"
450
+ ? "[Split Worker replacement declined]\nContinue with the existing Split Worker and the current task."
451
+ : "[Split Worker proposal declined]\nContinue this task in the current Driver session using the plan you already prepared.";
452
+ await resumeDriver(session, declinedText);
308
453
  return { ok: true, status: "declined" };
309
454
  }
310
455
  return acceptProposal(session, proposal, msg, ws);
@@ -318,18 +463,11 @@ function attachWorkerProposal(ctx) {
318
463
  return true;
319
464
  }
320
465
 
321
- // Retired as a required path: the tool is no longer offered to any model.
322
- // Kept in the module (with its handlers and the client message handler) so an
323
- // older client mid-proposal still resolves compatibly.
324
466
  function getToolDefs(session) {
325
- return [];
326
- }
327
-
328
- function retiredToolDefs(session) {
329
467
  if (!isEligible(session)) return [];
330
468
  return [{
331
469
  name: "propose_worker",
332
- description: "After making a concise plan for an implementation-heavy task, suggest that the user run the execution with another model in a visible Split 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.",
470
+ description: "Propose a visible Split Worker for implementation-heavy execution. This non-mutating tool only shows the user a card where they choose vendor, model, and effort. It never creates a session or delegates work until the user accepts.",
333
471
  inputSchema: buildShape({
334
472
  summary: { type: "string", description: "One short sentence explaining why a Split Worker is useful for this task." },
335
473
  plan: { type: "string", description: "A concise numbered implementation plan to show in the approval card." },
@@ -337,27 +475,21 @@ function attachWorkerProposal(ctx) {
337
475
  recommendedVendor: { type: "string", description: "Optional installed vendor id for the Split Worker." },
338
476
  recommendedModel: { type: "string", description: "Optional exact Split Worker model id. Omit when uncertain." },
339
477
  recommendedEffort: { type: "string", description: "Optional reasoning effort: minimal, low, medium, high, xhigh, or max." },
340
- }, ["summary", "plan", "message"]),
478
+ recommendationRationale: { type: "string", description: "Concise Driver-authored explanation of why the recommended vendor, model, and effort fit this exact task." },
479
+ }, ["summary", "plan", "message", "recommendationRationale"]),
341
480
  handler: function (args) { return propose(args || {}, session); },
342
481
  }];
343
482
  }
344
483
 
345
484
  function getSystemPrompt(session) {
346
485
  if (!isEligible(session)) return "";
347
- // Retired: a qualified Driver creates and manages its Split Worker on its
348
- // own authority, so the model is never told to propose one. The tool and
349
- // its message handler remain so an older client's in-flight proposal frame
350
- // still resolves instead of erroring.
351
- return "";
486
+ return "For implementation-heavy work, use propose_worker before substantial execution. Recommend an exact available vendor, model, and effort, and give a concise recommendationRationale explaining why all three fit the task. Clay always records and shows the runtime configuration card. In full-access mode Clay may auto-accept that exact validated recommendation; otherwise it waits for the user's explicit choice. After posting, end the turn. If accepted, Clay creates the exact Driver/Split Worker pair and delegates the proposed task once. If declined, continue in this Driver session. Do not call send_to_partner while unpaired.";
352
487
  }
353
488
 
354
489
  return {
355
490
  getToolDefs: getToolDefs,
356
- // The retired definitions, retained only so an older client's in-flight
357
- // proposal still resolves and so that compatibility path stays testable.
358
- // Never mounted for any model: getToolDefs returns nothing.
359
- retiredToolDefs: retiredToolDefs,
360
491
  getSystemPrompt: getSystemPrompt,
492
+ proposeReplacement: proposeReplacement,
361
493
  handleMessage: handleMessage,
362
494
  respondToProposal: respondToProposal,
363
495
  };
@@ -365,5 +497,4 @@ function attachWorkerProposal(ctx) {
365
497
 
366
498
  module.exports = {
367
499
  attachWorkerProposal: attachWorkerProposal,
368
- isFableSession: isFableSession,
369
500
  };
package/lib/project.js CHANGED
@@ -51,9 +51,11 @@ var { attachSessionDocument } = require("./project-session-document");
51
51
  var { attachCapsuleCatalog } = require("./project-capsule-catalog");
52
52
  var { attachProjectWorkspaceQuery } = require("./project-workspace-query");
53
53
  var { attachProjectLogs } = require("./project-logs");
54
+ var { attachProjectLogFeedbackDelivery } = require("./project-log-feedback-delivery");
54
55
  var { attachProjectMateKnowledge } = require("./project-mate-knowledge");
55
56
  var { attachSplitGroups } = require("./session-split-groups");
56
57
  var toolControlMcp = require("./tool-control-mcp-server");
58
+ var capsuleFloor = require("./capsule-display-floor");
57
59
  var toolLlm = require("./tool-llm");
58
60
  // project-notifications is attached globally in server.js, passed via opts.notificationsModule
59
61
 
@@ -64,6 +66,10 @@ function createMateToolControlMcp(adapter, config) {
64
66
  var manifests = config.list(config.userId) || [];
65
67
  var tools = [];
66
68
  for (var i = 0; i < manifests.length; i++) {
69
+ // Skills go dark when the declarative Display floor does, so a Capsule
70
+ // the human cannot operate is never listed to a Mate either. The guard
71
+ // validates the Capsule's own Display tree, not a claim about it.
72
+ if (!capsuleFloor.hasUsableFloor(manifests[i])) continue;
67
73
  tools.push({
68
74
  id: manifests[i].id,
69
75
  name: manifests[i].name,
@@ -670,6 +676,12 @@ function createProjectContext(opts) {
670
676
  isMate: isMate,
671
677
  mateId: mateId,
672
678
  });
679
+ var _logFeedbackDelivery = attachProjectLogFeedbackDelivery({
680
+ sm: sm,
681
+ getSdk: function () { return sdk; },
682
+ onProcessingChanged: onProcessingChanged,
683
+ getLinuxUserForSession: getLinuxUserForSession,
684
+ });
673
685
  var _projectLogs = attachProjectLogs({
674
686
  service: opts.projectLogsService || null,
675
687
  sm: sm,
@@ -679,6 +691,7 @@ function createProjectContext(opts) {
679
691
  mateId: mateId,
680
692
  sendTo: sendTo,
681
693
  getClients: function () { return clients; },
694
+ onFeedback: _logFeedbackDelivery.deliver,
682
695
  });
683
696
  var _mateKnowledge = attachProjectMateKnowledge({
684
697
  service: opts.mateKnowledgeService || null,
@@ -384,6 +384,24 @@ textarea.tool-input { min-height: 92px; resize: vertical; }
384
384
  .tool-pagination { flex-wrap: wrap; justify-content: flex-start; }
385
385
  }
386
386
 
387
+ /* Rich Display frame: the additive sandboxed element above the floor. */
388
+ .home-tool-frame-wrap { width: 100%; }
389
+ .home-tool-frame { display: block; width: 100%; min-height: 240px; border: 0; border-radius: 10px; background: var(--bg); }
390
+ .home-tool-frame-toggle {
391
+ display: block; margin: 6px 0 0 auto; padding: 3px 10px;
392
+ border: 1px solid var(--border-subtle); border-radius: 999px;
393
+ background: transparent; color: var(--text-muted); font-size: 11px; cursor: pointer;
394
+ }
395
+ .home-tool-frame-toggle:hover { color: var(--text); border-color: var(--accent); }
396
+
397
+ /* Host-side attribution flash when the other seat's act changes state. */
398
+ @keyframes capsule-remote-act-flash {
399
+ 0% { box-shadow: 0 0 0 2px var(--accent); }
400
+ 100% { box-shadow: 0 0 0 2px transparent; }
401
+ }
402
+ .capsule-remote-act { animation: capsule-remote-act-flash 0.8s ease-out; }
403
+
387
404
  @media (prefers-reduced-motion: reduce) {
388
405
  .tool-button, .tool-input, .tool-select { transition: none; }
406
+ .capsule-remote-act { animation: none; }
389
407
  }
@@ -22,6 +22,72 @@
22
22
  .home-sidebar-recent-item .home-sidebar-recent-row:hover,
23
23
  .home-conversations-sheet-item .home-conversations-sheet-row:hover { background: transparent; }
24
24
 
25
+ .home-conversations-driver-header {
26
+ display: flex;
27
+ align-items: stretch;
28
+ }
29
+
30
+ .home-conversations-driver-header > .home-conversations-sheet-item {
31
+ min-width: 0;
32
+ flex: 1;
33
+ }
34
+
35
+ .home-conversations-driver-toggle {
36
+ width: 32px;
37
+ padding: 0;
38
+ border: 0;
39
+ border-radius: 8px;
40
+ display: inline-flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ flex-shrink: 0;
44
+ color: var(--text-dimmer);
45
+ background: transparent;
46
+ cursor: pointer;
47
+ }
48
+
49
+ .home-conversations-driver-toggle:hover,
50
+ .home-conversations-driver-toggle:focus-visible {
51
+ color: var(--text);
52
+ background: rgba(var(--overlay-rgb), 0.05);
53
+ outline: none;
54
+ }
55
+
56
+ .home-conversations-driver-toggle .lucide {
57
+ width: 14px;
58
+ height: 14px;
59
+ transition: transform 0.15s ease;
60
+ }
61
+
62
+ .home-conversations-driver-toggle[aria-expanded="true"] .lucide {
63
+ transform: rotate(90deg);
64
+ }
65
+
66
+ .home-conversations-driver-header:not(:has(.home-conversations-sheet-item)) .home-conversations-driver-toggle {
67
+ width: 100%;
68
+ min-height: 38px;
69
+ padding: 0 10px;
70
+ justify-content: flex-start;
71
+ gap: 9px;
72
+ font: inherit;
73
+ font-size: 11px;
74
+ text-align: left;
75
+ }
76
+
77
+ .home-conversations-driver-header:not(:has(.home-conversations-sheet-item)) .home-conversations-driver-toggle span:first-of-type {
78
+ flex: 1;
79
+ }
80
+
81
+ .home-conversations-worker-children {
82
+ margin-left: 16px;
83
+ padding-left: 10px;
84
+ border-left: 1px solid color-mix(in srgb, var(--text-dimmer) 22%, transparent);
85
+ }
86
+
87
+ .home-conversations-worker-item .home-conversations-sheet-row {
88
+ padding-block: 8px;
89
+ }
90
+
25
91
  .home-sidebar-recent-row:focus-visible,
26
92
  .home-conversations-sheet-row:focus-visible {
27
93
  position: relative;
@@ -478,6 +478,72 @@
478
478
  line-height: 1.45;
479
479
  }
480
480
 
481
+ .home-sidebar-driver-header {
482
+ display: flex;
483
+ align-items: stretch;
484
+ }
485
+
486
+ .home-sidebar-driver-header > .home-sidebar-recent-item {
487
+ min-width: 0;
488
+ flex: 1;
489
+ }
490
+
491
+ .home-sidebar-driver-toggle {
492
+ width: 27px;
493
+ padding: 0;
494
+ border: 0;
495
+ border-radius: 7px;
496
+ display: inline-flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ flex-shrink: 0;
500
+ color: var(--text-dimmer);
501
+ background: transparent;
502
+ cursor: pointer;
503
+ }
504
+
505
+ .home-sidebar-driver-toggle:hover,
506
+ .home-sidebar-driver-toggle:focus-visible {
507
+ color: var(--text);
508
+ background: rgba(var(--overlay-rgb), 0.05);
509
+ outline: none;
510
+ }
511
+
512
+ .home-sidebar-driver-toggle .lucide {
513
+ width: 13px;
514
+ height: 13px;
515
+ transition: transform 0.15s ease;
516
+ }
517
+
518
+ .home-sidebar-driver-toggle[aria-expanded="true"] .lucide {
519
+ transform: rotate(90deg);
520
+ }
521
+
522
+ .home-sidebar-worker-children {
523
+ margin-left: 13px;
524
+ padding-left: 8px;
525
+ border-left: 1px solid color-mix(in srgb, var(--text-dimmer) 22%, transparent);
526
+ }
527
+
528
+ .home-sidebar-worker-item .home-sidebar-recent-row {
529
+ min-height: 38px;
530
+ }
531
+
532
+ .home-sidebar-orphan-toggle {
533
+ width: 100%;
534
+ min-height: 34px;
535
+ padding: 0 8px;
536
+ justify-content: flex-start;
537
+ gap: 7px;
538
+ font: inherit;
539
+ font-size: 11px;
540
+ text-align: left;
541
+ }
542
+
543
+ .home-sidebar-orphan-toggle span:first-of-type {
544
+ flex: 1;
545
+ }
546
+
481
547
  .home-sidebar-expand {
482
548
  position: absolute;
483
549
  top: 8px;