clay-server 4.2.0-beta.3 → 4.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.
Files changed (34) hide show
  1. package/lib/driver-continuation-access.js +54 -0
  2. package/lib/driver-continuation-lease.js +52 -0
  3. package/lib/driver-continuation-pair-transfer.js +86 -0
  4. package/lib/driver-continuation-pair.js +82 -0
  5. package/lib/driver-continuation-record.js +40 -0
  6. package/lib/driver-continuation-startup.js +68 -0
  7. package/lib/driver-continuation-transaction.js +61 -0
  8. package/lib/driver-continuation-trigger.js +110 -0
  9. package/lib/project-driver-continuation.js +499 -0
  10. package/lib/project-pair-lifecycle.js +73 -73
  11. package/lib/project-pair-replacement-state.js +5 -1
  12. package/lib/project-session-handoff.js +48 -55
  13. package/lib/project-user-message.js +14 -0
  14. package/lib/project-worker-proposal.js +1 -1
  15. package/lib/project.js +49 -2
  16. package/lib/public/css/driver-continuation.css +56 -0
  17. package/lib/public/css/overlays.css +44 -16
  18. package/lib/public/css/pane.css +9 -5
  19. package/lib/public/index.html +3 -3
  20. package/lib/public/modules/app-messages.js +14 -0
  21. package/lib/public/modules/driver-continuation-state.js +76 -0
  22. package/lib/public/modules/driver-continuation.js +260 -0
  23. package/lib/public/modules/permission-control.js +8 -3
  24. package/lib/public/style.css +1 -0
  25. package/lib/sdk-bridge.js +77 -0
  26. package/lib/session-handoff-discovery.js +443 -0
  27. package/lib/session-handoff-mcp-server.js +32 -4
  28. package/lib/session-pair-prompts.js +5 -0
  29. package/lib/session-pair-turn-control.js +19 -3
  30. package/lib/session-split-group-anchors.js +44 -0
  31. package/lib/session-split-groups.js +209 -59
  32. package/lib/sessions.js +20 -2
  33. package/lib/ws-schema.js +6 -0
  34. package/package.json +1 -1
@@ -1,18 +1,3 @@
1
- // Split Worker lifecycle internals: bounded status, transactional replacement,
2
- // and the per-generation evaluation ledger. The Driver-facing tool posts a
3
- // user-controlled runtime card before accepted replacement reaches this module.
4
- //
5
- // Everything is bound to the exact live pair. The Driver is re-resolved from
6
- // the split store on every call, its structural capability is re-checked every time, and
7
- // ownership must match on both sessions. No active-tab or global-session
8
- // fallback exists in this module.
9
- //
10
- // What replacement deliberately does NOT do: delete history. Dissolving a pair
11
- // leaves both sessions in the project exactly as close_partner already does, so
12
- // the previous Worker's conversation stays browsable and recoverable under the
13
- // existing session semantics. There is no archive concept in the repo to hook
14
- // into, and inventing a destructive one would lose work.
15
-
16
1
  var eligibility = require("./session-driver-eligibility");
17
2
  var replacementState = require("./project-pair-replacement-state");
18
3
  var pairUsage = require("./project-pair-usage");
@@ -66,9 +51,6 @@ function continuityStatus(session) {
66
51
  };
67
52
  }
68
53
 
69
- // The current task, as a bounded preview of the delegated instruction only.
70
- // Never the transcript: a Driver deciding reuse needs to know what the Worker
71
- // is on, not to re-read its conversation.
72
54
  function activityStatus(session) {
73
55
  var token = session._pairDelegation || null;
74
56
  return {
@@ -185,10 +167,6 @@ function attachPairLifecycle(ctx) {
185
167
  }
186
168
  function resolveDriverPair(caller) {
187
169
  if (!caller) throw new Error("pair lifecycle tools require a session-bound tool server");
188
- // Exact live object identity, before anything is read off the caller. A
189
- // tool handler captured by a query outlives the session it was bound to, so
190
- // a stale object — or a different object that happens to carry the same
191
- // localId — must not be able to act as the Driver.
192
170
  if (sm.sessions.get(caller.localId) !== caller) {
193
171
  throw new Error("this session is no longer live; the pair tools are bound to an exact session");
194
172
  }
@@ -268,6 +246,38 @@ function attachPairLifecycle(ctx) {
268
246
  };
269
247
  }
270
248
 
249
+ function sourcePairRegistered(caller, worker) {
250
+ try {
251
+ if (sm.sessions.get(caller.localId) !== caller || sm.sessions.get(worker.localId) !== worker) return false;
252
+ if ((caller.ownerId || null) !== (worker.ownerId || null)) return false;
253
+ var group = store.groupForMember(caller.localId);
254
+ return !!(group && group.pair && group.pair.driverId === caller.localId &&
255
+ group.pair.workerId === worker.localId && group.members.indexOf(caller.localId) !== -1 &&
256
+ group.members.indexOf(worker.localId) !== -1);
257
+ } catch (e) { return false; }
258
+ }
259
+
260
+ function restoreSourcePair(ws, caller, worker) {
261
+ try {
262
+ return store.create(ws, {
263
+ members: [caller.localId, worker.localId],
264
+ pair: { driverId: caller.localId, workerId: worker.localId },
265
+ });
266
+ } catch (e) { return { ok: false, error: e.message || String(e) }; }
267
+ }
268
+
269
+ function replacementTargetRegistered(caller, oldWorker) {
270
+ try {
271
+ if (sm.sessions.get(caller.localId) !== caller || sm.sessions.get(oldWorker.localId) !== oldWorker) return null;
272
+ var group = store.groupForMember(caller.localId);
273
+ if (!group || !group.pair || group.pair.driverId !== caller.localId ||
274
+ group.pair.workerId === oldWorker.localId) return null;
275
+ var target = sm.sessions.get(group.pair.workerId);
276
+ if (!target || sm.sessions.get(target.localId) !== target) return null;
277
+ return (caller.ownerId || null) === (target.ownerId || null) ? target : null;
278
+ } catch (e) { return null; }
279
+ }
280
+
271
281
  function replacePartner(args, caller) {
272
282
  var resolved = resolveDriverPair(caller);
273
283
  var group = resolved.group;
@@ -278,10 +288,6 @@ function attachPairLifecycle(ctx) {
278
288
  var replay = replacementState.replayValue(replacementEntry);
279
289
  if (replay) return Promise.resolve(replay);
280
290
 
281
- // Validate the replacement while the old pair is still fully intact. An
282
- // uninstalled vendor, an unavailable model or an unsupported effort must
283
- // never cost the user their running Worker, so this precedes the interrupt,
284
- // the permission cancellation and the dissolve.
285
291
  try {
286
292
  ctx.preflightWorkerForDriver(caller, {
287
293
  workerVendor: args.workerVendor,
@@ -309,51 +315,39 @@ function attachPairLifecycle(ctx) {
309
315
  throw validationError;
310
316
  }
311
317
 
312
- if (blocked) {
313
- replacementState.stage(replacementEntry.transaction, "stopping_source");
314
- replacementEntry.transaction.sourceStopped = true;
315
- if (ctx.markInterruption) ctx.markInterruption(oldWorker, "driver", "The Driver accepted replacement of this Worker generation.");
316
- oldWorker.taskStopRequested = true;
317
- if (oldWorker.abortController) {
318
- try { oldWorker.abortController.abort(); } catch (e) {}
319
- }
320
- if (oldWorker._pairDelegation && typeof ctx.finishDelegation === "function") {
321
- if (typeof ctx.completeInterruptedTask === "function") ctx.completeInterruptedTask(oldWorker, caller, oldWorker._pairDelegation);
322
- ctx.finishDelegation(group, caller, oldWorker, oldWorker._pairDelegation);
318
+ var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
319
+ try {
320
+ if (blocked) {
321
+ replacementState.stage(replacementEntry.transaction, "stopping_source");
322
+ replacementEntry.transaction.sourceStopped = true;
323
+ if (ctx.markInterruption) ctx.markInterruption(oldWorker, "driver", "The Driver accepted replacement of this Worker generation.");
324
+ oldWorker.taskStopRequested = true;
325
+ if (oldWorker.abortController) {
326
+ try { oldWorker.abortController.abort(); } catch (e) {}
327
+ }
328
+ if (oldWorker._pairDelegation && typeof ctx.finishDelegation === "function") {
329
+ if (typeof ctx.completeInterruptedTask === "function") ctx.completeInterruptedTask(oldWorker, caller, oldWorker._pairDelegation);
330
+ ctx.finishDelegation(group, caller, oldWorker, oldWorker._pairDelegation);
331
+ }
323
332
  }
324
- }
325
333
 
326
- // Any permission decision the old Worker was waiting on dies with the pair.
327
- replacementState.stage(replacementEntry.transaction, "cancelling_source_waits");
328
- if (typeof ctx.cancelWorkerPermissions === "function") {
329
- ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
330
- }
334
+ replacementState.stage(replacementEntry.transaction, "cancelling_source_waits");
335
+ if (typeof ctx.cancelWorkerPermissions === "function") {
336
+ ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
337
+ }
331
338
 
332
- var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
333
- replacementState.stage(replacementEntry.transaction, "dissolving_source_pair");
334
- var dissolved = store.dissolve(ws, { id: group.id });
335
- if (!dissolved.ok) {
339
+ replacementState.stage(replacementEntry.transaction, "dissolving_source_pair");
340
+ var dissolved = store.dissolve(ws, { id: group.id });
341
+ if (!dissolved.ok) throw new Error(dissolved.error || "could not dissolve the existing pair");
342
+ } catch (precreateError) {
343
+ var sourceIntact = sourcePairRegistered(caller, oldWorker);
344
+ var precreateRestored = sourceIntact ? { ok: true } : restoreSourcePair(ws, caller, oldWorker);
345
+ var rollback = precreateRestored && precreateRestored.ok ? (sourceIntact ? "safe_retry" : "source_pair_restored") : "source_pair_restore_failed";
336
346
  turnControl.releaseCreation(creationTicket);
337
- replacementState.fail(replacementEntry.transaction, "dissolve_failed", new Error(dissolved.error || "could not dissolve"), "not_required");
338
- throw new Error(dissolved.error || "could not dissolve the existing pair");
347
+ replacementState.fail(replacementEntry.transaction, "precreate_failed", precreateError, rollback);
348
+ throw precreateError;
339
349
  }
340
350
 
341
- // History is preserved: the old Worker session stays in the project.
342
- //
343
- // Preflight already cleared every input, but the group write itself can
344
- // still fail, so the dissolve is rolled back rather than leaving the Driver
345
- // with no pair at all. The restored group is an equivalent record with the
346
- // same members and roles; its group id is newly issued.
347
- //
348
- // What rollback can and cannot undo:
349
- // - Idle replacement failure is fully recoverable. The Worker session,
350
- // its history and its still-open ledger generation are all preserved,
351
- // and the pair is restored.
352
- // - An explicit interrupt=true is NOT reversible. Stopping a mid-turn
353
- // Worker aborts its query and cancels the permission decisions it was
354
- // waiting on; a later creation failure cannot resume that turn. The
355
- // session and its history survive, but the interrupted work does not
356
- // come back. The error says so rather than implying a clean restore.
357
351
  var created;
358
352
  try {
359
353
  replacementState.stage(replacementEntry.transaction, "creating_target");
@@ -363,18 +357,26 @@ function attachPairLifecycle(ctx) {
363
357
  workerEffort: typeof args.workerEffort === "string" ? args.workerEffort : "",
364
358
  });
365
359
  } catch (e) {
366
- var restored = store.create(ws, {
367
- members: [caller.localId, oldWorker.localId],
368
- pair: { driverId: caller.localId, workerId: oldWorker.localId },
369
- });
370
- var restoreNote = restored && restored.ok
360
+ var target = replacementTargetRegistered(caller, oldWorker);
361
+ if (target) {
362
+ var targetClosed = closeGeneration(caller, oldWorker);
363
+ var targetGeneration = recordGenerationStart(caller, target);
364
+ replacementEntry.transaction.targetWorkerId = target.localId;
365
+ replacementEntry.transaction.targetGeneration = targetGeneration;
366
+ replacementEntry.transaction.previousGeneration = targetClosed ? targetClosed.generation : null;
367
+ replacementState.fail(replacementEntry.transaction, "postcommit_delivery_failed", e, "target_created");
368
+ throw new Error("the replacement Split Worker was created, but its delivery confirmation failed. Reuse Worker " + target.localId + " generation " + targetGeneration + " with send_to_partner; do not replace it again. " + (e.message || String(e)));
369
+ }
370
+ var restored = restoreSourcePair(ws, caller, oldWorker);
371
+ var restoredLive = restored && restored.ok && sourcePairRegistered(caller, oldWorker);
372
+ var restoreNote = restoredLive
371
373
  ? "The previous pair was restored with its session, history and open generation intact."
372
374
  : "The previous pair could not be restored; both sessions are intact and unpaired.";
373
375
  var interruptNote = blocked
374
376
  ? " Its interrupted turn cannot be resumed, because stopping it was explicitly requested."
375
377
  : "";
376
378
  turnControl.releaseCreation(creationTicket);
377
- replacementState.fail(replacementEntry.transaction, "creation_failed", e, restored && restored.ok ? "source_pair_restored" : "source_pair_restore_failed");
379
+ replacementState.fail(replacementEntry.transaction, "creation_failed", e, restoredLive ? "source_pair_restored" : "source_pair_restore_failed");
378
380
  throw new Error("could not create the replacement Split Worker: " + (e.message || String(e)) +
379
381
  ". " + restoreNote + interruptNote);
380
382
  }
@@ -384,7 +386,6 @@ function attachPairLifecycle(ctx) {
384
386
  if (typeof sm.saveSessionFile === "function") sm.saveSessionFile(caller);
385
387
  }
386
388
  var generation = recordGenerationStart(caller, created.worker);
387
-
388
389
  var result = {
389
390
  status: "replaced",
390
391
  previousWorkerSessionId: oldWorker.localId,
@@ -410,7 +411,6 @@ function attachPairLifecycle(ctx) {
410
411
  });
411
412
  }
412
413
 
413
- // No global or cross-user ranking is formed from Worker evaluations.
414
414
  function validateEvaluation(raw) {
415
415
  var input = typeof raw === "string" ? { outcome: raw } : (raw && typeof raw === "object" ? raw : {});
416
416
  var outcome = typeof input.outcome === "string" ? input.outcome.trim().toLowerCase() : "";
@@ -46,7 +46,11 @@ function fail(transaction, stageName, error, rollback) {
46
46
  transaction.stage = stageName;
47
47
  transaction.updatedAt = Date.now();
48
48
  transaction.completedAt = transaction.updatedAt;
49
- transaction.failure = { message: error && (error.message || String(error)) || "Unknown replacement failure", rollback: rollback || "not_required" };
49
+ transaction.failure = {
50
+ message: error && (error.message || String(error)) || "Unknown replacement failure",
51
+ rollback: rollback || "not_required",
52
+ retryable: rollback === "safe_retry" || (stageName === "creation_failed" && rollback === "source_pair_restored"),
53
+ };
50
54
  transaction.sourcePairRestored = rollback === "source_pair_restored" ? true : (rollback === "source_pair_restore_failed" ? false : null);
51
55
  return transaction;
52
56
  }
@@ -1,13 +1,10 @@
1
1
  var yoke = require("./yoke");
2
2
  var contextBuilder = require("./session-handoff-context");
3
3
  var sessionHandoffMcp = require("./session-handoff-mcp-server");
4
+ var discoveryModule = require("./session-handoff-discovery");
4
5
 
5
6
  var MAX_HANDOFF_CHAIN_DEPTH = 5;
6
7
 
7
- function toolResult(text) {
8
- return Promise.resolve({ content: [{ type: "text", text: text }] });
9
- }
10
-
11
8
  function toolError(message) {
12
9
  return Promise.resolve({
13
10
  content: [{ type: "text", text: "Error: " + message }],
@@ -26,6 +23,16 @@ function hasUserContext(session) {
26
23
 
27
24
  function attachSessionHandoff(ctx) {
28
25
  var sm = ctx.sm;
26
+ var discovery = discoveryModule.attachSessionHandoffDiscovery({
27
+ sm: sm,
28
+ isMate: ctx.isMate,
29
+ projectSlug: ctx.projectSlug || "current-project",
30
+ isMultiUser: ctx.isMultiUser,
31
+ getProjectAccess: ctx.getProjectAccess,
32
+ canAccessSession: ctx.canAccessSession,
33
+ findUserById: ctx.findUserById,
34
+ isDriverOperatedSession: ctx.isDriverOperatedSession,
35
+ });
29
36
 
30
37
  function sendResult(ws, ok, details) {
31
38
  ctx.sendTo(ws, Object.assign({ type: "session_handoff_result", ok: ok }, details || {}));
@@ -172,14 +179,16 @@ function attachSessionHandoff(ctx) {
172
179
  var chain = [];
173
180
  var visited = new Set();
174
181
  var sourceSessionId = boundSession.handoff.sourceSessionId;
175
- var ownerId = boundSession.ownerId || null;
176
182
  for (var depth = 0; depth < MAX_HANDOFF_CHAIN_DEPTH; depth++) {
177
183
  var visitKey = String(sourceSessionId);
178
184
  if (visited.has(visitKey)) break;
179
185
  visited.add(visitKey);
180
186
  var source = sm.sessions.get(sourceSessionId);
181
187
  if (!source) return { error: "Source session was not found: " + sourceSessionId };
182
- if ((source.ownerId || null) !== ownerId) return { error: "Source session owner does not match this session" };
188
+ if (!discovery.authorize(boundSession, source)) {
189
+ if (typeof ctx.isMultiUser !== "function" || !ctx.isMultiUser()) return { error: "Source session owner does not match this session" };
190
+ return { error: "Source session access denied" };
191
+ }
183
192
  chain.push(source);
184
193
  if (!source.handoff || source.handoff.sourceSessionId === undefined || source.handoff.sourceSessionId === null) break;
185
194
  sourceSessionId = source.handoff.sourceSessionId;
@@ -195,61 +204,45 @@ function attachSessionHandoff(ctx) {
195
204
  return null;
196
205
  }
197
206
 
198
- function boundedInteger(value, fallback, min, max) {
199
- if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
200
- return Math.min(max, Math.max(min, Math.floor(value)));
201
- }
202
-
203
- function formatHistory(source, args) {
204
- var history = Array.isArray(source.history) ? source.history : [];
205
- var limit = boundedInteger(args.limit, 30, 1, 100);
206
- var offset = typeof args.offset === "number" && Number.isFinite(args.offset)
207
- ? boundedInteger(args.offset, 0, 0, history.length)
208
- : Math.max(0, history.length - limit);
209
- var slice = history.slice(offset, offset + limit);
210
- var end = offset + slice.length;
211
- var out = [];
212
- out.push("# " + (source.title || "Untitled session") + " — " + (source.vendor || "unknown") + "/" + source.localId);
213
- out.push("Showing entries " + (slice.length ? offset + 1 : 0) + "-" + end + " of " + history.length + "\n");
214
- for (var i = 0; i < slice.length; i++) {
215
- var entry = slice[i];
216
- if (!entry) continue;
217
- var label;
218
- var text = "";
219
- if (entry.type === "user_message") {
220
- label = "USER";
221
- text = entry.text || "";
222
- } else if (entry.type === "delta") {
223
- label = "ASSISTANT";
224
- text = entry.text || "";
225
- } else if (entry.type === "tool_executing" || entry.type === "tool_result") {
226
- label = "TOOL";
227
- text = (entry.name || "") + (entry.input ? " " + JSON.stringify(entry.input).substring(0, 120) : "");
228
- } else {
229
- continue;
230
- }
231
- if (text.length > 800) text = text.substring(0, 800) + "...";
232
- out.push("[" + label + "] " + text);
233
- }
234
- return out.join("\n");
235
- }
236
-
237
- function readHandoffSource(args, boundSession) {
207
+ function readHandoffSource(args, boundSession, generation) {
238
208
  if (!boundSession) return toolError("read_handoff_source requires a session-bound tool server");
239
- if (!boundSession.handoff) return toolError("this session does not have a handoff source");
209
+ if (!boundSession.handoff) return Promise.resolve(discovery.fail(boundSession, generation, "this session does not have a handoff source"));
240
210
  var resolved = resolveSourceChain(boundSession);
241
- if (resolved.error) return toolError(resolved.error);
211
+ if (resolved.error) return Promise.resolve(discovery.fail(boundSession, generation, resolved.error));
242
212
  var source = findSource(resolved.chain, args.sourceSessionId);
243
- if (!source) return toolError("sourceSessionId is not in this session's handoff chain");
244
- return toolResult(formatHistory(source, args));
213
+ if (!source) return Promise.resolve(discovery.fail(boundSession, generation, "sourceSessionId is not in this session's handoff chain"));
214
+ return Promise.resolve(discovery.legacy(boundSession, source, args, generation));
245
215
  }
246
216
 
247
- function getToolDefs(boundSession) {
217
+ function getToolDefs(boundSession, queryGeneration) {
248
218
  if (ctx.isMate) return [];
249
- if (boundSession && !boundSession.handoff) return [];
250
- return sessionHandoffMcp.getToolDefs({
251
- read: function (args) { return readHandoffSource(args, boundSession || null); },
252
- });
219
+ if (boundSession && !discovery.authorize(boundSession, boundSession)) return [];
220
+ var generation = boundSession
221
+ ? (Number.isInteger(queryGeneration) ? queryGeneration : discovery.captureGeneration(boundSession))
222
+ : null;
223
+ var handlers = {
224
+ read: function (args) {
225
+ if (!boundSession) return toolError("read_handoff_source requires a session-bound tool server");
226
+ if (!boundSession.handoff) return toolError("read_handoff_source requires a handoff session");
227
+ return readHandoffSource(args, boundSession, generation);
228
+ },
229
+ };
230
+ if (boundSession && !boundSession.handoff) delete handlers.read;
231
+ if (boundSession) {
232
+ handlers.listOtherDrivers = function (args) {
233
+ try { return Promise.resolve(discovery.list(boundSession, args || {}, generation)); }
234
+ catch (e) { return toolError(e.message || String(e)); }
235
+ };
236
+ handlers.searchOtherDrivers = function (args) {
237
+ try { return Promise.resolve(discovery.search(boundSession, args || {}, generation)); }
238
+ catch (e) { return toolError(e.message || String(e)); }
239
+ };
240
+ handlers.readOtherDriver = function (args) {
241
+ try { return Promise.resolve(discovery.read(boundSession, args || {}, generation)); }
242
+ catch (e) { return toolError(e.message || String(e)); }
243
+ };
244
+ }
245
+ return sessionHandoffMcp.getToolDefs(handlers);
253
246
  }
254
247
 
255
248
  function createMcpServer(adapter, boundSession) {
@@ -1,5 +1,6 @@
1
1
  var path = require("path");
2
2
  var fs = require("fs");
3
+ var continuationLease = require("./driver-continuation-lease");
3
4
 
4
5
  /**
5
6
  * Attach user-message handler and remaining small handlers
@@ -433,6 +434,19 @@ function attachUserMessage(ctx) {
433
434
  finishPendingEarly(false, "Pending messages cannot be delivered to a Driver-operated Split Worker");
434
435
  return true;
435
436
  }
437
+ if (!pendingInternal && session._driverContinuationLease && pendingMessageQueue) {
438
+ var continuationQueued = pendingMessageQueue.admit(session, msg, ws && ws._clayUser);
439
+ if (continuationQueued.ok) {
440
+ messageDelivery.acknowledge(ws, deliveryReceipt, null, session);
441
+ sendTo(ws, { type: "message_queued", clientMessageId: msg.clientMessageId || null,
442
+ sessionId: session.localId, projectSlug: slug, state: "pending",
443
+ paused: pendingMessageQueue.isPaused(session), revision: pendingMessageQueue.getRevision() });
444
+ continuationLease.cancelForHumanMessage(session);
445
+ } else {
446
+ sendTo(ws, { type: "error", text: continuationQueued.error || "Unable to queue message" });
447
+ }
448
+ return true;
449
+ }
436
450
  if (!pendingInternal && pendingMessageQueue && (pendingMessageQueue.hasActive(session) || session.isProcessing || session._queryStarting || session._awaitingTurnResult)) {
437
451
  var queuedResult = pendingMessageQueue.admit(session, msg, ws && ws._clayUser);
438
452
  if (queuedResult.ok) {
@@ -339,7 +339,7 @@ function attachWorkerProposal(ctx) {
339
339
  } else if (result.status === "running") {
340
340
  followup = "[Split Worker execution is still running]\nUse read_partner to inspect progress before completing the task.";
341
341
  } else {
342
- followup = "[Split Worker execution failed]\nInspect the failure and delegate a narrower follow-up to the Split Worker when implementation work remains.\n\n" + (result.error || result.response || "Unknown Split Worker error.");
342
+ followup = "[Split Worker execution failed]\nThe existing Split Worker may still be reusable. Inspect partner_status and send a narrower follow-up with send_to_partner when the Worker and pair still exist; do not replace it merely because its task or transport failed. Retry replacement only when the replacement transaction reports that no new Worker was created and the failure is explicitly safe to retry.\n\n" + (result.error || result.response || "Unknown Split Worker error.");
343
343
  }
344
344
  await resumeDriver(session, followup);
345
345
  }
package/lib/project.js CHANGED
@@ -56,6 +56,8 @@ var { attachCapsuleTurn } = require("./project-capsule-turn");
56
56
  var workspaceSessionRef = require("./workspace-query-service").sessionRef;
57
57
  var { attachSessionPair } = require("./project-session-pair");
58
58
  var { attachSessionHandoff } = require("./project-session-handoff");
59
+ var { attachDriverContinuation } = require("./project-driver-continuation");
60
+ var driverContinuationAccess = require("./driver-continuation-access");
59
61
  var { attachSessionNotes, composeSystemPrompts } = require("./project-session-notes");
60
62
  var { attachSessionDocument } = require("./project-session-document");
61
63
  var { attachCapsuleCatalog } = require("./project-capsule-catalog");
@@ -854,16 +856,41 @@ function createProjectContext(opts) {
854
856
  });
855
857
  var _sessionHandoff = attachSessionHandoff({
856
858
  cwd: cwd,
859
+ projectSlug: slug,
857
860
  sm: sm,
858
861
  isMate: isMate,
859
862
  splitStore: _splitGroups.store,
860
863
  getSdk: function () { return sdk; },
861
864
  sendTo: sendTo,
862
865
  usersModule: usersModule,
866
+ isMultiUser: usersModule.isMultiUser,
867
+ getProjectAccess: opts.getProjectAccess || function () { return { visibility: "public", ownerId: projectOwnerId || null }; },
868
+ canAccessSession: usersModule.canAccessSession,
869
+ findUserById: usersModule.findUserById,
870
+ isDriverOperatedSession: function (session) { return !isMate && _sessionPair.workerPermission.isDriverOperated(session); },
863
871
  adapters: adapters,
864
872
  getLinuxUserForSession: getLinuxUserForSession,
865
873
  onProcessingChanged: onProcessingChanged,
866
874
  });
875
+ var _driverContinuation = attachDriverContinuation({
876
+ sm: sm,
877
+ isMate: isMate,
878
+ projectSlug: slug,
879
+ splitStore: _splitGroups.store,
880
+ pendingMessageQueue: _pendingMessageQueue,
881
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
882
+ isMultiUser: usersModule.isMultiUser,
883
+ isDriverOperatedSession: function (session) { return !isMate && _sessionPair.workerPermission.isDriverOperated(session); },
884
+ authorizeSession: function (session, ownerId) {
885
+ return driverContinuationAccess.authorize({ usersModule: usersModule, projectSlug: slug,
886
+ getProjectAccess: opts.getProjectAccess, canAccessProjectSlug: opts.canAccessProjectSlug }, session, ownerId);
887
+ },
888
+ getSdk: function () { return sdk; },
889
+ getLinuxUserForSession: getLinuxUserForSession,
890
+ onProcessingChanged: onProcessingChanged,
891
+ consumePendingMessage: function (session) { return _userMessage && _userMessage.consumePendingMessage(session); },
892
+ sendTo: sendTo,
893
+ });
867
894
  var _debate = null;
868
895
  var _debateProposal = attachDebateProposal({
869
896
  cwd: cwd,
@@ -1287,6 +1314,7 @@ function createProjectContext(opts) {
1287
1314
  _sessionPair.getSystemPrompt(session),
1288
1315
  _sessionNotes.getSystemPrompt(session),
1289
1316
  _sessionHandoff.getSystemPrompt(session),
1317
+ _driverContinuation.getSystemPrompt(session),
1290
1318
  _sessionDocument.getSystemPrompt(session),
1291
1319
  _capsuleCatalog.getSystemPrompt(session),
1292
1320
  _projectLogs.getSystemPrompt(session),
@@ -1304,6 +1332,7 @@ function createProjectContext(opts) {
1304
1332
  return _sessionPair.getToolDefs(session)
1305
1333
  .concat(_sessionNotes.getToolDefs(session))
1306
1334
  .concat(_sessionHandoff.getToolDefs(session))
1335
+ .concat(_driverContinuation.getToolDefs(session, Number(session._sdkQueryGeneration || 0)))
1307
1336
  .concat(_sessionDocument.getToolDefs(session))
1308
1337
  .concat(_debateProposal.getToolDefs(session))
1309
1338
  .concat(_mateCreationProposal.getToolDefs(session))
@@ -1714,6 +1743,7 @@ function createProjectContext(opts) {
1714
1743
  // --- Sessions, config, project mgmt (delegated to project-sessions.js) ---
1715
1744
  if (_sessionPair.handleMessage(ws, msg)) return;
1716
1745
  if (_sessionHandoff.handleMessage(ws, msg)) return;
1746
+ if (_driverContinuation.handleMessage(ws, msg)) return;
1717
1747
  if (_splitGroups.handleMessage(ws, msg)) return;
1718
1748
  if (_models.handleMessage(ws, msg)) return;
1719
1749
  if (_scheduledTasks.handleMessage(ws, msg)) return;
@@ -2226,7 +2256,7 @@ function createProjectContext(opts) {
2226
2256
  inputSchema: normalizeToolSchema(noteTools[nti].inputSchema),
2227
2257
  });
2228
2258
  }
2229
- var handoffTools = _sessionHandoff.getToolDefs(boundSession);
2259
+ var handoffTools = _sessionHandoff.getToolDefs(boundSession, queryGeneration);
2230
2260
  for (var hti = 0; hti < handoffTools.length; hti++) {
2231
2261
  tools.push({
2232
2262
  server: "clay-handoff",
@@ -2235,6 +2265,15 @@ function createProjectContext(opts) {
2235
2265
  inputSchema: normalizeToolSchema(handoffTools[hti].inputSchema),
2236
2266
  });
2237
2267
  }
2268
+ var continuationTools = _driverContinuation.getToolDefs(boundSession, queryGeneration);
2269
+ for (var cti = 0; cti < continuationTools.length; cti++) {
2270
+ tools.push({
2271
+ server: "clay-continuation",
2272
+ name: continuationTools[cti].name,
2273
+ description: continuationTools[cti].description || continuationTools[cti].name,
2274
+ inputSchema: normalizeToolSchema(continuationTools[cti].inputSchema),
2275
+ });
2276
+ }
2238
2277
  var documentTools = _sessionDocument.getToolDefs(boundSession);
2239
2278
  for (var dti = 0; dti < documentTools.length; dti++) {
2240
2279
  tools.push({
@@ -2321,13 +2360,21 @@ function createProjectContext(opts) {
2321
2360
  }
2322
2361
  }
2323
2362
  if (boundSession && serverName === "clay-handoff") {
2324
- var handoffTools = _sessionHandoff.getToolDefs(boundSession);
2363
+ var handoffTools = _sessionHandoff.getToolDefs(boundSession, queryGeneration);
2325
2364
  for (var hti = 0; hti < handoffTools.length; hti++) {
2326
2365
  if (handoffTools[hti].name === toolName && typeof handoffTools[hti].handler === "function") {
2327
2366
  return Promise.resolve(handoffTools[hti].handler(args || {}));
2328
2367
  }
2329
2368
  }
2330
2369
  }
2370
+ if (boundSession && serverName === "clay-continuation") {
2371
+ var continuationTools = _driverContinuation.getToolDefs(boundSession, queryGeneration);
2372
+ for (var cti = 0; cti < continuationTools.length; cti++) {
2373
+ if (continuationTools[cti].name === toolName && typeof continuationTools[cti].handler === "function") {
2374
+ return Promise.resolve(continuationTools[cti].handler(args || {}));
2375
+ }
2376
+ }
2377
+ }
2331
2378
  if (boundSession && serverName === "clay-documents") {
2332
2379
  var documentTools = _sessionDocument.getToolDefs(boundSession);
2333
2380
  for (var dti = 0; dti < documentTools.length; dti++) {
@@ -0,0 +1,56 @@
1
+ .driver-continuation-card,
2
+ .driver-continuation-context {
3
+ width: min(680px, calc(100% - 24px));
4
+ margin: 14px auto;
5
+ border: 1px solid var(--border);
6
+ border-radius: 14px;
7
+ background: var(--bg-alt);
8
+ color: var(--text);
9
+ box-shadow: 0 10px 28px rgba(0, 0, 0, .08);
10
+ }
11
+
12
+ .driver-continuation-card { padding: 18px; }
13
+ .driver-continuation-card header,
14
+ .driver-continuation-context { display: flex; align-items: flex-start; gap: 12px; }
15
+ .driver-continuation-context { padding: 15px 17px; }
16
+ .driver-continuation-card header > div,
17
+ .driver-continuation-context-copy { display: grid; gap: 3px; min-width: 0; flex: 1; }
18
+ .driver-continuation-mark { display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 auto; border-radius: 9px; color: var(--accent2); background: color-mix(in srgb, var(--accent2) 12%, transparent); }
19
+ .driver-continuation-kicker { font-size: 10px; line-height: 1.2; font-weight: 700; letter-spacing: .1em; color: var(--text-muted); }
20
+ .driver-continuation-status { margin-left: auto; white-space: nowrap; font-size: 12px; color: var(--text-muted); }
21
+ .driver-continuation-reason,
22
+ .driver-continuation-context p { margin: 12px 0; color: var(--text-secondary); line-height: 1.5; }
23
+ .driver-continuation-summary { display: grid; grid-template-columns: 80px 1fr; gap: 7px 12px; padding: 13px; border-radius: 10px; background: var(--bg); }
24
+ .driver-continuation-summary dt,
25
+ .driver-continuation-details dt { font-size: 11px; font-weight: 700; color: var(--text-muted); }
26
+ .driver-continuation-summary dd,
27
+ .driver-continuation-details dd { margin: 0; white-space: pre-wrap; line-height: 1.45; }
28
+ .driver-continuation-details { margin-top: 12px; color: var(--text-secondary); }
29
+ .driver-continuation-details summary { cursor: pointer; font-size: 12px; font-weight: 600; }
30
+ .driver-continuation-details dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 12px; margin: 12px 0 0; }
31
+ .driver-continuation-error { padding: 10px; border-radius: 8px; color: var(--error); background: var(--error-8); }
32
+ .driver-continuation-context .driver-continuation-error { color: var(--error); }
33
+ .driver-continuation-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
34
+ .driver-continuation-action,
35
+ .driver-continuation-source { min-height: 36px; border: 1px solid var(--border); border-radius: 9px; padding: 8px 13px; font: inherit; cursor: pointer; }
36
+ .driver-continuation-action.primary { display: inline-flex; align-items: center; gap: 7px; border-color: var(--accent2); background: color-mix(in srgb, var(--accent2) 16%, var(--bg)); color: var(--accent2); }
37
+ .driver-continuation-action.secondary,
38
+ .driver-continuation-source { background: transparent; color: var(--text); }
39
+ .driver-continuation-action:disabled,
40
+ .driver-continuation-source:disabled { cursor: not-allowed; opacity: .45; }
41
+ .driver-continuation-action:focus-visible,
42
+ .driver-continuation-source:focus-visible,
43
+ .driver-continuation-details summary:focus-visible { outline: 2px solid var(--accent2); outline-offset: 2px; }
44
+ .driver-continuation-source { justify-self: start; margin-top: 5px; padding: 5px 9px; min-height: 30px; font-size: 12px; }
45
+
46
+ @media (max-width: 600px) {
47
+ .driver-continuation-card,
48
+ .driver-continuation-context { width: calc(100% - 16px); }
49
+ .driver-continuation-card { padding: 14px; }
50
+ .driver-continuation-card header { flex-wrap: wrap; }
51
+ .driver-continuation-status { width: 100%; margin-left: 42px; }
52
+ .driver-continuation-summary,
53
+ .driver-continuation-details dl { grid-template-columns: 1fr; }
54
+ .driver-continuation-actions { flex-direction: column-reverse; }
55
+ .driver-continuation-action { width: 100%; justify-content: center; }
56
+ }