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
@@ -10,6 +10,7 @@ var attachHomeDebates = require("./server-home-debates").attachHomeDebates, atta
10
10
  var homeChatEvents = require("./server-home-chat-events");
11
11
  var homeCapsuleCreation = require("./server-home-capsule-creation");
12
12
  var sessionTitlePolicy = require("./session-title-policy");
13
+ var sessionProvenance = require("./session-provenance");
13
14
  var historyToHomeChat = homeChatEvents.historyToHomeChat;
14
15
  var transformEvent = homeChatEvents.transformEvent;
15
16
  function attachHomeChat(deps) {
@@ -83,8 +84,14 @@ function attachHomeChat(deps) {
83
84
  var sessionManager = found.ctx.getSessionManager();
84
85
  if (!sessionManager) return [];
85
86
  var result = [];
87
+ var visibleSessions = [];
86
88
  sessionManager.sessions.forEach(function (session) {
87
89
  if (!ownsSession(session, userId)) return;
90
+ visibleSessions.push(session);
91
+ });
92
+ var hierarchy = sessionProvenance.hierarchyFor(visibleSessions);
93
+ for (var vi = 0; vi < visibleSessions.length; vi++) {
94
+ var session = visibleSessions[vi];
88
95
  if (sessionTitlePolicy.repairLegacySearchTitle(session)
89
96
  && typeof sessionManager.saveSessionFile === "function") {
90
97
  sessionManager.saveSessionFile(session);
@@ -102,12 +109,16 @@ function attachHomeChat(deps) {
102
109
  createdAt: createdAt,
103
110
  lastActivity: lastActivity,
104
111
  isProcessing: !!session.isProcessing,
112
+ sessionRole: hierarchy[session.localId].role,
113
+ parentSessionId: hierarchy[session.localId].parentSessionId == null ? null : sessionReference(sessionManager.sessions.get(hierarchy[session.localId].parentSessionId)),
114
+ parentAvailable: hierarchy[session.localId].parentAvailable,
115
+ workerGeneration: hierarchy[session.localId].generation,
105
116
  });
106
117
  if (session.debateSetupMode === true) result[result.length - 1].debatePlanning = true;
107
118
  if (session.homeDebatePhase) result[result.length - 1].debatePhase = session.homeDebatePhase;
108
119
  if (session.mateCreationMode === true) result[result.length - 1].mateCreation = true;
109
120
  if (session.homeMateCreationPhase) result[result.length - 1].mateCreationPhase = session.homeMateCreationPhase;
110
- });
121
+ }
111
122
  result.sort(function (a, b) { return b.lastActivity - a.lastActivity; });
112
123
  return result;
113
124
  }
@@ -2,11 +2,15 @@ var toolsRegistry = require("./tools-registry");
2
2
  var toolStorage = require("./tool-storage");
3
3
  var crypto = require("crypto");
4
4
  var toolLlm = require("./tool-llm");
5
+ var capsuleFloor = require("./capsule-display-floor");
6
+ var capsuleServerRuntimes = require("./capsule-server-runtimes");
7
+ var capsuleFrameServer = require("./capsule-frame-server");
5
8
 
6
9
  function attachTools(deps) {
7
10
  var users = deps.users;
8
11
  var projects = deps.projects;
9
- var serverLogic = Object.create(null);
12
+ var serverRuntimes = deps.serverRuntimes || capsuleServerRuntimes;
13
+ var frameServer = deps.frameServer || capsuleFrameServer.createCapsuleFrameServer({ tlsOptions: deps.tlsOptions || null });
10
14
  var controlTimeoutMs = deps.controlTimeoutMs || 15000;
11
15
  var llmControlTimeoutMs = deps.llmControlTimeoutMs || 90000;
12
16
  var pendingControl = Object.create(null);
@@ -137,20 +141,47 @@ function attachTools(deps) {
137
141
  });
138
142
  }
139
143
 
144
+ // One act pipeline for server-runtime Capsules. The human's button click and
145
+ // the Mate's clay_tool_act call both arrive here, so the same Logic enforces
146
+ // the same rules for both and neither gets a private path. The seat is set
147
+ // from the server-resolved actor, never from caller-supplied text.
148
+ //
149
+ // The caller gets state and nothing else: a Mate never sees Display. Every
150
+ // successful act additionally pushes its causal {seq, actor, action,
151
+ // previous, next} event to the user's own connected clients, so a watching
152
+ // Display re-renders on any Logic change no matter who caused it.
153
+ async function controlServerCapsule(userId, installed, actor, callerId, kind, payload) {
154
+ var runtime = serverRuntimes.createRuntime(installed.manifest.id, contextFor(userId));
155
+ if (!runtime) throw new Error("No trusted server runtime is registered for this capsule.");
156
+ var context = { userId: userId, actor: actor, callerId: callerId };
157
+ if (kind === "snapshot") return runtime.snapshot(context);
158
+ if (kind === "act") {
159
+ var result = await runtime.act(context, payload.actionId, payload.args || {});
160
+ if (result && result.event) {
161
+ broadcastToUser(userId, { type: "tool_server_event", toolId: installed.manifest.id, event: result.event });
162
+ return result.state;
163
+ }
164
+ return result;
165
+ }
166
+ if (kind === "set" && runtime.set) return runtime.set(context, payload.controlId, payload.value);
167
+ throw new Error("The server-runtime capsule does not support '" + kind + "'.");
168
+ }
169
+
140
170
  function controlForMate(userId, mateId, toolId, kind, payload) {
141
171
  payload = payload || {};
142
172
  var ctx = contextFor(userId);
143
173
  toolsRegistry.listTools(ctx);
144
174
  var installed = toolsRegistry.getTool(ctx, toolId);
145
175
  if (!installed) return Promise.reject(new Error("Tool is not installed."));
176
+ // Skills are gated by Display: the Mate loses a Capsule at the same moment
177
+ // the human loses the declarative floor it is operated through.
178
+ if (!capsuleFloor.hasUsableFloor(installed.manifest, installed.uiTree)) {
179
+ return Promise.reject(new Error("This Capsule has no usable human Display, so it is unavailable to Mates."));
180
+ }
146
181
  if (installed.manifest.runtime === "server") {
147
- var adapter = serverLogic[toolId];
148
- if (!adapter) return Promise.reject(new Error("No trusted server runtime is registered for this capsule."));
149
- var context = { userId: userId, callerId: mateId };
150
- if (kind === "snapshot") return Promise.resolve(adapter.snapshot(context));
151
- if (kind === "act") return Promise.resolve(adapter.act(context, payload.actionId, payload.args || {}));
152
- if (kind === "set" && adapter.set) return Promise.resolve(adapter.set(context, payload.controlId, payload.value));
153
- return Promise.reject(new Error("The server-runtime capsule does not support '" + kind + "'."));
182
+ return Promise.resolve().then(function () {
183
+ return controlServerCapsule(userId, installed, "mate", mateId, kind, payload);
184
+ });
154
185
  }
155
186
  var permissions = installed.manifest.permissions || [];
156
187
  var timeoutMs = kind === "act" && permissions.indexOf("llm") !== -1 ? llmControlTimeoutMs : controlTimeoutMs;
@@ -262,8 +293,18 @@ function attachTools(deps) {
262
293
  return { removed: true, toolId: toolId };
263
294
  }
264
295
 
296
+ // The human half of the shared server-Capsule pipeline. Same Logic, same
297
+ // rules, no AI in the path.
298
+ function controlForUser(userId, toolId, kind, payload) {
299
+ var ctx = contextFor(userId);
300
+ var installed = toolsRegistry.getTool(ctx, toolId);
301
+ if (!installed) throw new Error("Tool is not installed.");
302
+ if (installed.manifest.runtime !== "server") throw new Error("This Capsule does not run on the server.");
303
+ return controlServerCapsule(userId, installed, "human", "user", kind, payload || {});
304
+ }
305
+
265
306
  function handleMessage(ws, msg) {
266
- var messageTypes = ["tools_list", "tool_get", "tool_install", "tool_remove", "tool_storage_op", "tool_llm_op", "tool_llm_config_get", "tool_control_response", "tool_source_get", "tool_mate_access_set"];
307
+ var messageTypes = ["tools_list", "tool_get", "tool_install", "tool_remove", "tool_storage_op", "tool_llm_op", "tool_llm_config_get", "tool_control_response", "tool_source_get", "tool_mate_access_set", "tool_server_control", "tool_frame_url"];
267
308
  if (!msg || messageTypes.indexOf(msg.type) === -1) return false;
268
309
  var userId;
269
310
  if (users.isMultiUser()) {
@@ -312,6 +353,52 @@ function attachTools(deps) {
312
353
  });
313
354
  return true;
314
355
  }
356
+ if (msg.type === "tool_frame_url") {
357
+ // The rich element renders on a separate anonymous origin, so the frame
358
+ // URL is minted here, on the authenticated socket, as a short-lived
359
+ // one-time token bound to this user's tools root. The rich element is
360
+ // additive only: it exists solely when the floor already validated at
361
+ // registration, and losing it never costs the human the floor.
362
+ Promise.resolve().then(function () {
363
+ var ctx = contextFor(userId);
364
+ var installed = toolsRegistry.getTool(ctx, msg.toolId);
365
+ if (!installed) throw new Error("Tool is not installed.");
366
+ if (!installed.hasRichDisplay) throw new Error("This Capsule ships no rich Display element.");
367
+ return frameServer.issueFrameUrl(ctx, msg.toolId);
368
+ }).then(function (frame) {
369
+ send(ws, { type: "tool_frame_url_state", toolId: msg.toolId, requestId: msg.requestId || null, ok: true, frame: frame });
370
+ }).catch(function (error) {
371
+ send(ws, {
372
+ type: "tool_frame_url_state",
373
+ toolId: msg.toolId,
374
+ requestId: msg.requestId || null,
375
+ ok: false,
376
+ error: error && error.message ? error.message : "The rich Display is unavailable.",
377
+ });
378
+ });
379
+ return true;
380
+ }
381
+ if (msg.type === "tool_server_control") {
382
+ Promise.resolve().then(function () {
383
+ return controlForUser(userId, msg.toolId, msg.kind, {
384
+ actionId: msg.actionId,
385
+ args: msg.args || {},
386
+ controlId: msg.controlId,
387
+ value: msg.value,
388
+ });
389
+ }).then(function (state) {
390
+ send(ws, { type: "tool_server_state", toolId: msg.toolId, requestId: msg.requestId || null, ok: true, state: state });
391
+ }).catch(function (error) {
392
+ send(ws, {
393
+ type: "tool_server_state",
394
+ toolId: msg.toolId,
395
+ requestId: msg.requestId || null,
396
+ ok: false,
397
+ error: error && error.message ? error.message : "The Capsule action failed.",
398
+ });
399
+ });
400
+ return true;
401
+ }
315
402
  if (msg.type === "tool_install") {
316
403
  run(ws, msg, function () {
317
404
  installForMate(userId, {
@@ -366,6 +453,7 @@ function attachTools(deps) {
366
453
  handleMessage: handleMessage,
367
454
  installedManifests: installedManifests,
368
455
  controlForMate: controlForMate,
456
+ controlForUser: controlForUser,
369
457
  installForMate: installForMate,
370
458
  sourceForMate: sourceForMate,
371
459
  updateForMate: updateForMate,
package/lib/server.js CHANGED
@@ -1428,6 +1428,9 @@ function createServer(opts) {
1428
1428
  var toolsHandler = serverTools.attachTools({
1429
1429
  users: users,
1430
1430
  projects: projects,
1431
+ // The rich-Display frame listener must speak the same scheme as the app,
1432
+ // or the sandboxed frame would be blocked as mixed content under TLS.
1433
+ tlsOptions: tlsOptions,
1431
1434
  });
1432
1435
 
1433
1436
  // --- Mate handler ---
@@ -1,183 +1,38 @@
1
- // Driver eligibility: which models may hold the Driver role of a visible
2
- // Driver/Split Worker pair and receive the autonomous lifecycle tools.
1
+ // Driver capability for a visible Driver/Split Worker pair.
3
2
  //
4
- // There is no canonical tier helper in the repo to reuse. Claude models are
5
- // enumerated by the SDK at runtime, so there is no static list; Codex ships a
6
- // fixed catalog in yoke/adapters/codex.js. What both have in common is a tier
7
- // token in the model id and in its display name, which the existing
8
- // project-worker-proposal.js already keys on ("fable"). This module makes that
9
- // same idea explicit, ordered, and vendor-scoped.
10
- //
11
- // Ranks are per vendor family and are only ever compared within a family; no
12
- // cross-vendor ranking is implied or invented.
13
- //
14
- // claude family haiku 1 < sonnet 2 < opus 3 < fable 4
15
- // real ids: claude-haiku-4-5-20251001, claude-sonnet-5,
16
- // claude-opus-5, claude-fable-5
17
- // Driver threshold: fable
18
- //
19
- // codex family gpt-5.2 1 < gpt-5.5 2 < luna 3 < sol 4 < terra 5
20
- // real ids: the CODEX_MODELS catalog, strongest first
21
- // Driver threshold: sol
22
- //
23
- // Matching is by token, not by exact id, so a later release in the same family
24
- // (claude-fable-6, gpt-5.7-terra) stays eligible without a code change. A
25
- // model in a known family whose tier token is unrecognized is NOT eligible:
26
- // unknown capability fails closed rather than inheriting the threshold.
27
- //
28
- // Any other vendor is not eligible for the Driver role at all, because no
29
- // equivalent tier metadata exists to compare against. Those vendors remain
30
- // perfectly usable as Workers.
31
-
32
- var models = require("./project-models");
33
-
34
- var FAMILIES = {
35
- claude: {
36
- label: "Claude",
37
- threshold: 4,
38
- thresholdName: "Fable",
39
- // Longest/most specific tokens first so "haiku" cannot shadow a future
40
- // compound name. Each entry is a token searched in id and display name.
41
- tiers: [
42
- { token: "fable", rank: 4, name: "Fable" },
43
- { token: "opus", rank: 3, name: "Opus" },
44
- { token: "sonnet", rank: 2, name: "Sonnet" },
45
- { token: "haiku", rank: 1, name: "Haiku" },
46
- ],
47
- },
48
- codex: {
49
- label: "OpenAI",
50
- threshold: 4,
51
- thresholdName: "Sol",
52
- tiers: [
53
- { token: "terra", rank: 5, name: "Terra" },
54
- { token: "sol", rank: 4, name: "Sol" },
55
- { token: "luna", rank: 3, name: "Luna" },
56
- { token: "gpt-5.5", rank: 2, name: "GPT-5.5" },
57
- { token: "gpt-5.2", rank: 1, name: "GPT-5.2" },
58
- ],
59
- },
60
- };
61
-
62
- function familyFor(vendor) {
63
- var key = String(vendor || "").toLowerCase();
64
- return Object.prototype.hasOwnProperty.call(FAMILIES, key) ? FAMILIES[key] : null;
65
- }
66
-
67
- // Every string the catalog knows this model by. The catalog entry is found with
68
- // the repo's own matcher so an alias ("fable"), an id, or a resolvedModel all
69
- // resolve to the same entry, and the display name participates in tier
70
- // detection exactly as project-worker-proposal.js already relies on.
71
- function searchTextFor(vendor, model, modelsByVendor) {
72
- var parts = [String(model || "")];
73
- var catalog = (modelsByVendor && modelsByVendor[vendor]) || [];
74
- for (var i = 0; i < catalog.length; i++) {
75
- if (!models.modelEntryMatches(catalog[i], model)) continue;
76
- var entry = catalog[i];
77
- if (typeof entry === "string") {
78
- parts.push(entry);
79
- } else {
80
- parts.push(entry.value || "");
81
- parts.push(entry.id || "");
82
- parts.push(entry.resolvedModel || "");
83
- parts.push(entry.displayName || "");
84
- parts.push(entry.name || "");
85
- }
86
- break;
87
- }
88
- return parts.join(" ").toLowerCase();
89
- }
90
-
91
- // Resolve the tier of one model inside its vendor family. Returns null when
92
- // the vendor has no tier metadata or the tier token is unrecognized.
93
- function resolveTier(vendor, model, modelsByVendor) {
94
- var family = familyFor(vendor);
95
- if (!family || !model) return null;
96
- var text = searchTextFor(vendor, model, modelsByVendor);
97
- for (var i = 0; i < family.tiers.length; i++) {
98
- if (text.indexOf(family.tiers[i].token) !== -1) {
99
- return {
100
- vendor: String(vendor).toLowerCase(),
101
- family: family.label,
102
- rank: family.tiers[i].rank,
103
- name: family.tiers[i].name,
104
- threshold: family.threshold,
105
- thresholdName: family.thresholdName,
106
- };
107
- }
108
- }
109
- return null;
110
- }
111
-
112
- // The hard invariant. Returns { ok, tier, error } and never throws. `error` is
113
- // a complete English sentence suitable for returning to a tool caller.
114
- function evaluateDriverModel(vendor, model, modelsByVendor) {
115
- var family = familyFor(vendor);
116
- if (!family) {
117
- return {
118
- ok: false,
119
- tier: null,
120
- error: "The Driver role requires a Claude model of Fable tier or higher, or an OpenAI model of " +
121
- "Sol tier or higher. Vendor \"" + String(vendor || "unknown") + "\" has no comparable tier " +
122
- "metadata, so it cannot take the Driver role. It can still be used for the Split Worker.",
123
- };
3
+ // Model and vendor choice belongs to the user. A session does not need to
4
+ // clear a model-tier threshold to coordinate a Split Worker; runtime catalog
5
+ // validation belongs to session-pair-factory.js and applies equally to every
6
+ // requested role. The only session-level exclusion here is structural: an
7
+ // embedded terminal has no project chat surface on which to host the Driver
8
+ // controls or the paired pane.
9
+
10
+ var sessionProvenance = require("./session-provenance");
11
+
12
+ function evaluateDriverSession(session) {
13
+ if (!session) {
14
+ return { ok: false, error: "No session was bound to this request." };
124
15
  }
125
- if (!model) {
126
- return {
127
- ok: false,
128
- tier: null,
129
- error: "This session has no resolved model yet, so its " + family.label +
130
- " tier cannot be confirmed. The Driver role requires " + family.thresholdName +
131
- " tier or higher.",
132
- };
133
- }
134
- var tier = resolveTier(vendor, model, modelsByVendor);
135
- if (!tier) {
136
- return {
137
- ok: false,
138
- tier: null,
139
- error: "Model \"" + model + "\" is not a recognized " + family.label +
140
- " tier, so it cannot take the Driver role. The Driver role requires " +
141
- family.thresholdName + " tier or higher.",
142
- };
143
- }
144
- if (tier.rank < tier.threshold) {
16
+ if (session.mode === "tui") {
145
17
  return {
146
18
  ok: false,
147
- tier: tier,
148
- error: "The Driver role requires a " + family.label + " model of " + family.thresholdName +
149
- " tier or higher. This session is " + tier.name + " tier.",
19
+ error: "An embedded terminal session cannot take the Driver role.",
150
20
  };
151
21
  }
152
- return { ok: true, tier: tier, error: null };
153
- }
154
-
155
- // The session-level form. Resolves the session's effective model the same way
156
- // the rest of the server does: the session's own model, else the vendor
157
- // default. Identity is read from the session object, never from a caller.
158
- function evaluateDriverSession(session, sm) {
159
- if (!session) return { ok: false, tier: null, error: "No session was bound to this request." };
160
- if (session.mode === "tui") {
22
+ if (sessionProvenance.isWorker(session)) {
161
23
  return {
162
24
  ok: false,
163
- tier: null,
164
- error: "An embedded terminal session cannot take the Driver role.",
25
+ error: "A Split Worker session cannot take the Driver role.",
165
26
  };
166
27
  }
167
- var vendor = session.vendor || "claude";
168
- var fallback = (sm && sm.defaultModelByVendor && sm.defaultModelByVendor[vendor]) || "";
169
- var model = session.model || fallback;
170
- return evaluateDriverModel(vendor, model, sm && sm.modelsByVendor);
28
+ return { ok: true, error: null };
171
29
  }
172
30
 
173
- function isEligibleDriverSession(session, sm) {
174
- return evaluateDriverSession(session, sm).ok;
31
+ function isEligibleDriverSession(session) {
32
+ return evaluateDriverSession(session).ok;
175
33
  }
176
34
 
177
35
  module.exports = {
178
- FAMILIES: FAMILIES,
179
- evaluateDriverModel: evaluateDriverModel,
180
36
  evaluateDriverSession: evaluateDriverSession,
181
37
  isEligibleDriverSession: isEligibleDriverSession,
182
- resolveTier: resolveTier,
183
38
  };
@@ -2,9 +2,9 @@
2
2
  //
3
3
  // Extracted from project-session-pair.js, which operates a pair once it
4
4
  // exists; this module is only about how one comes into being. Both the
5
- // explicit "Add Split Worker" flow and the Driver-initiated
6
- // send_to_partner-creates-a-pair flow land here, so vendor validation, owner
7
- // derivation and the group record are written in exactly one place.
5
+ // explicit "Add Split Worker", accepted proposal, and transactional replacement
6
+ // flows land here, so vendor validation, owner derivation and the group record
7
+ // are written in exactly one place.
8
8
  //
9
9
  // Ownership always comes from the connection or the Driver session, never from
10
10
  // the incoming message.
@@ -22,11 +22,10 @@
22
22
  //
23
23
  // Two ordering rules matter and are load-bearing:
24
24
  //
25
- // 1. Nothing is created until everything is validated. Driver eligibility and
25
+ // 1. Nothing is created until everything is validated. Driver capability and
26
26
  // both runtime requests are settled first, so a rejected request leaves no
27
- // orphan session behind. For a newly requested Driver the tier is checked
28
- // against the model that session would actually resolve to, before either
29
- // session exists.
27
+ // orphan session behind. Any installed, available model may be the Driver;
28
+ // model choice is the user's rather than a server-side tier policy.
30
29
  // 2. `preflightRuntime` is side-effect free, so a caller that is about to
31
30
  // destroy something (replacement dissolving a live pair) can validate the
32
31
  // replacement first and abort while the old pair is still intact.
@@ -34,6 +33,7 @@
34
33
  var driverEligibility = require("./session-driver-eligibility");
35
34
  var models = require("./project-models");
36
35
  var yoke = require("./yoke");
36
+ var sessionProvenance = require("./session-provenance");
37
37
 
38
38
  function attachPairFactory(ctx) {
39
39
  var sm = ctx.sm;
@@ -87,8 +87,8 @@ function attachPairFactory(ctx) {
87
87
  };
88
88
  }
89
89
 
90
- // The vendor a Driver-initiated Worker gets when none is named: prefer a
91
- // different engine from the Driver's own, so the pair does not double up.
90
+ // The default Worker vendor for an accepted proposal or replacement prefers
91
+ // a different engine from the Driver's own, so the pair does not double up.
92
92
  function defaultWorkerVendorFor(driver) {
93
93
  var installed = sm.installedVendors || [];
94
94
  if (installed.length === 0) throw new Error("no coding agent is installed for a Split Worker session");
@@ -140,14 +140,6 @@ function attachPairFactory(ctx) {
140
140
  groupName = undefined; // auto name from member titles
141
141
  } else {
142
142
  driverRuntime = preflightRuntime(driverSpec, "claude");
143
- // The tier is judged on the model this session would really resolve to:
144
- // the explicit request if there is one, else that vendor's default.
145
- var effectiveDriverModel = driverRuntime.model ||
146
- (sm.defaultModelByVendor && sm.defaultModelByVendor[driverRuntime.vendor]) || "";
147
- var newVerdict = driverEligibility.evaluateDriverModel(
148
- driverRuntime.vendor, effectiveDriverModel, sm.modelsByVendor
149
- );
150
- if (!newVerdict.ok) throw new Error(newVerdict.error);
151
143
  groupName = "Agent pair";
152
144
  }
153
145
 
@@ -176,18 +168,29 @@ function attachPairFactory(ctx) {
176
168
  worker.title = "Split Worker · " + ((yoke.getVendorInfo(workerRuntime.vendor) || {}).displayName || workerRuntime.vendor);
177
169
  if (workerRuntime.explicitEffort) worker.loopSettings = { effort: workerRuntime.effort };
178
170
 
179
- var result = store.create(ws, {
180
- members: [driver.localId, worker.localId],
181
- pair: { driverId: driver.localId, workerId: worker.localId },
182
- name: groupName,
183
- });
184
- if (!result.ok) {
185
- // The group is the last step, so a failure here would otherwise strand
186
- // the sessions this call just made. Only sessions created by this call
187
- // are removed; an existing Driver is never touched.
171
+ var result;
172
+ try {
173
+ // Creation provenance is durable and independent from the active split
174
+ // group. Persist both ends before the group's broadcast so the Worker
175
+ // never flashes as unrelated and survives a restart with its parentage.
176
+ sessionProvenance.markWorker(driver, worker, sm.sessions);
177
+ if (typeof sm.saveSessionFile === "function") {
178
+ sm.saveSessionFile(driver);
179
+ sm.saveSessionFile(worker);
180
+ }
181
+
182
+ result = store.create(ws, {
183
+ members: [driver.localId, worker.localId],
184
+ pair: { driverId: driver.localId, workerId: worker.localId },
185
+ name: groupName,
186
+ });
187
+ if (!result.ok) throw new Error(result.error);
188
+ } catch (e) {
189
+ // Any failure after session creation must remove exactly the sessions
190
+ // made by this call. An existing Driver is never removed.
188
191
  removeCreated(worker);
189
192
  removeCreated(createdDriver);
190
- throw new Error(result.error);
193
+ throw e;
191
194
  }
192
195
  sm.broadcastSessionList();
193
196
  return { driver: driver, worker: worker, group: result.group };
@@ -202,7 +205,7 @@ function attachPairFactory(ctx) {
202
205
  }
203
206
 
204
207
  function createWorkerForDriver(driver, args) {
205
- // Validates the Driver's eligibility and the whole Worker runtime before
208
+ // Validates the Driver's capability and the whole Worker runtime before
206
209
  // any session is made, so a rejection creates nothing.
207
210
  var runtime = preflightWorkerForDriver(driver, args);
208
211
  var ws = {
@@ -9,14 +9,11 @@ function getToolDefs(handlers, options) {
9
9
  var defs = [
10
10
  {
11
11
  name: "send_to_partner",
12
- description: "Delegate one concrete task to the visible Split Worker. If this session has no pair yet, Clay creates a real Split Worker session and opens it visibly in the right pane before starting the task — no approval step and no suggestion card. A completed turn does not end the Split Worker session: reuse the same Split Worker for follow-up implementation and corrections instead of taking over its work. Detached completions are pushed back automatically. A delegated turn cannot delegate back, so keep orchestration one hop deep.",
12
+ description: "Delegate one concrete follow-up task to the existing visible Split Worker. This tool is exposed only after the user has configured and accepted an exact pair. A completed turn does not end the Split Worker session: reuse the same Split Worker for follow-up implementation and corrections instead of taking over its work. Detached completions are pushed back automatically. A delegated turn cannot delegate back, so keep orchestration one hop deep.",
13
13
  inputSchema: buildShape({
14
14
  message: { type: "string", description: "The complete task or question for the partner." },
15
15
  wait: { type: "boolean", description: "Wait for the partner's turn to finish. Defaults to true." },
16
16
  timeoutSeconds: { type: "number", description: "Maximum wait in seconds, from 1 to 900. Defaults to 300." },
17
- workerVendor: { type: "string", description: "Optional Split Worker vendor to use only when creating a new pair. Defaults to a suitable installed vendor." },
18
- workerModel: { type: "string", description: "Optional Split Worker model to use only when creating a new pair." },
19
- workerEffort: { type: "string", description: "Optional Split Worker reasoning effort to use only when creating a new pair." },
20
17
  operationId: { type: "string", description: "Optional stable id for this delegation. Reusing it within the same human turn returns the original operation instead of sending twice." },
21
18
  }, ["message"]),
22
19
  handler: function (args) { return handlers.send(args || {}); },
@@ -52,21 +49,20 @@ function getToolDefs(handlers, options) {
52
49
  },
53
50
  {
54
51
  name: "replace_partner",
55
- description: "Replace your Split Worker with a fresh, compact one in a single operation: the old pair is dissolved, a new Worker session is created and opened visibly, and an optional task is delivered to it. The previous Worker's conversation is preserved and stays browsable. Use this when the current Worker is context-bloated, stale, or working on something unrelated to the next task. Refuses while the Worker is mid-turn unless you pass interrupt true.",
52
+ description: "Propose replacing the current Split Worker with a fresh runtime. This non-mutating call shows a user-controlled vendor, model, and effort card. Only acceptance performs the transactional replacement and delegates the supplied task once; decline keeps the existing pair and resumes the Driver. The previous Worker's conversation remains browsable. An active Worker requires interrupt true.",
56
53
  inputSchema: buildShape({
57
54
  interrupt: { type: "boolean", description: "Stop the Worker first if it is mid-turn or holding a delegated task. Required to replace an active Worker." },
58
- message: { type: "string", description: "Optional task to deliver to the new Worker immediately after it is created." },
59
- wait: { type: "boolean", description: "When a message is given, wait for that turn to finish. Defaults to true." },
60
- timeoutSeconds: { type: "number", description: "Maximum wait in seconds, from 1 to 900. Defaults to 300." },
55
+ message: { type: "string", description: "Task to deliver exactly once after the user accepts the replacement." },
61
56
  workerVendor: { type: "string", description: "Optional vendor for the new Worker. Must be installed and available." },
62
57
  workerModel: { type: "string", description: "Optional model for the new Worker. Must be offered by that vendor." },
63
58
  workerEffort: { type: "string", description: "Optional reasoning effort for the new Worker." },
59
+ recommendationRationale: { type: "string", description: "Concise Driver-authored explanation of why the recommended replacement vendor, model, and effort fit the next task." },
64
60
  evaluation: {
65
61
  type: "object",
66
62
  description: "Optional bounded assessment of the Worker being replaced, recorded against that exact generation.",
67
63
  },
68
64
  operationId: { type: "string", description: "Optional stable id for this replacement. Reusing it within the same human turn returns the original operation instead of replacing twice." },
69
- }),
65
+ }, ["message", "recommendationRationale"]),
70
66
  handler: function (args) { return handlers.replace(args || {}); },
71
67
  },
72
68
  {
@@ -1,10 +1,7 @@
1
1
  // Driver-facing system prompt text for the visible Driver/Split Worker pair.
2
2
  //
3
3
  // Pure data, extracted from project-session-pair.js so that module stays under
4
- // the size limit and so the guidance can be reviewed as prose. There is no
5
- // proposal or approval language here by design: a qualified Driver manages its
6
- // Worker on its own authority, so telling the model to suggest or ask would
7
- // contradict the tools it actually has.
4
+ // the size limit and so the guidance can be reviewed as prose.
8
5
 
9
6
  var DRIVER = [
10
7
  "You are the Driver of a visible Driver/Split Worker pair, and you manage that Split Worker yourself.",
@@ -17,18 +14,22 @@ var DRIVER = [
17
14
  "working on something unrelated, replace it instead of carrying that cost forward. Call partner_status to",
18
15
  "decide: it reports context tokens used and the ratio of its window, current activity, vendor/model/effort,",
19
16
  "history size and idle time, whether replacing is safe right now, and the results you recorded for earlier",
20
- "Worker generations. It returns no transcript, so it is cheap to consult.",
17
+ "Worker generations. It returns no transcript, so it is cheap to consult. Never tell the user that you will",
18
+ "reuse the current Worker before checking partner_status. If you previously expected reuse but the status leads",
19
+ "you to replace it, explicitly say that the decision changed and give the reason.",
21
20
  "",
22
- "Act on your own authority. You create, reuse, interrupt, replace, and close the Split Worker without asking",
23
- "the user and without posting any suggestion or approval card. send_to_partner creates and opens a visible",
24
- "Split Worker when none exists. replace_partner dissolves the current pair and opens a fresh compact Worker in",
25
- "one step, optionally delivering the next task with it; the replaced Worker keeps its conversation, so nothing",
26
- "is lost. Replacing an actively running Worker requires interrupt true, which stops it first. A human Stop",
21
+ "Use send_to_partner seamlessly for follow-up work in the existing pair. Runtime creation and replacement are",
22
+ "recorded through a configuration card before anything mutates. replace_partner always posts that card. When",
23
+ "this Driver is in full-access mode, Clay may auto-accept your exact server-validated recommendation; otherwise",
24
+ "the selected vendor, model, and effort remain pending until the user explicitly accepts them.",
25
+ "The replaced Worker keeps its conversation, so nothing is lost. Replacing an actively running Worker requires",
26
+ "interrupt true, which stops it first only after acceptance. A human Stop",
27
27
  "is authoritative: do not retry, send more work, or replace the Worker in the same turn. Clay blocks those",
28
28
  "actions until the human sends a new Driver message. Use close_partner when they ask to close the pane.",
29
29
  "",
30
- "Choose the Worker vendor, model, and effort for the task from what is actually installed and offered; an",
31
- "unavailable choice is refused rather than silently substituted. After a Worker generation finishes or is",
30
+ "Recommend a Worker vendor, model, and effort from what is actually installed and offered, and include a concise",
31
+ "rationale explaining why all three fit the task. The card remains visible as an audit trail even when full access",
32
+ "auto-accepts it; an unavailable choice is never auto-accepted. After a Worker generation finishes or is",
32
33
  "replaced, call record_partner_evaluation with succeeded, partial, failed, or abandoned and a short reason.",
33
34
  "Clay stores that against that exact generation alongside what it measured itself, and partner_status hands it",
34
35
  "back, so your next model choice can use observed results instead of guesswork. This is your own record for",
@@ -51,9 +52,8 @@ var UNPAIRED = [
51
52
  "Split Worker and its partner tools. Internal Sub-agents are a distinct execution mechanism, not a lexical",
52
53
  "category; use them only when the user clearly intends internal or background parallel delegation rather than a",
53
54
  "visible paired session. When ambiguity would materially change where work runs, ask a concise clarification",
54
- "instead of guessing. When a task is implementation-heavy, call send_to_partner directly: Clay creates the",
55
- "paired Split Worker session and opens it in the right pane automatically before delivering the task. Do not",
56
- "ask the user for permission to start one, do not ask them to enable split mode, and do not use a background",
55
+ "instead of guessing. When a task is implementation-heavy, call propose_worker with an exact runtime recommendation",
56
+ "and concise rationale. Clay records and shows the card before the visible pair is accepted or auto-accepted. Do not use a background",
57
57
  "Sub-agent as a substitute for a visible Split Worker.",
58
58
  ].join(" ").replace(/ {2,}/g, " ");
59
59