clay-server 4.0.0 → 4.1.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 (52) hide show
  1. package/lib/daemon-project-access.js +118 -0
  2. package/lib/daemon-projects.js +19 -5
  3. package/lib/daemon.js +19 -18
  4. package/lib/project-logs-access-scope.js +94 -0
  5. package/lib/project-logs-query.js +3 -0
  6. package/lib/project-logs-service.js +86 -84
  7. package/lib/project-logs-store.js +5 -3
  8. package/lib/project-pair-lifecycle.js +71 -61
  9. package/lib/project-pair-message.js +50 -0
  10. package/lib/project-pair-replacement-state.js +72 -0
  11. package/lib/project-pair-task-control.js +264 -0
  12. package/lib/project-pair-usage.js +73 -0
  13. package/lib/project-session-pair.js +115 -116
  14. package/lib/project-sessions.js +40 -6
  15. package/lib/project-worker-proposal.js +54 -57
  16. package/lib/project.js +27 -4
  17. package/lib/public/app.js +1 -0
  18. package/lib/public/css/command-palette.css +26 -0
  19. package/lib/public/css/icon-strip.css +40 -0
  20. package/lib/public/index.html +2 -2
  21. package/lib/public/modules/app-message-router.js +1 -1
  22. package/lib/public/modules/app-messages.js +9 -0
  23. package/lib/public/modules/project-access.js +141 -0
  24. package/lib/public/modules/project-grouping.js +22 -0
  25. package/lib/public/modules/search-clay-chat.js +47 -6
  26. package/lib/public/modules/search-clay-controls.js +86 -0
  27. package/lib/public/modules/sidebar-projects.js +14 -171
  28. package/lib/public/modules/worker-proposal.js +4 -0
  29. package/lib/public/style.css +1 -1
  30. package/lib/sdk-bridge.js +21 -1
  31. package/lib/server-admin.js +3 -3
  32. package/lib/server-global-ws.js +10 -0
  33. package/lib/server-home-chat-events.js +3 -0
  34. package/lib/server-home-chat.js +35 -0
  35. package/lib/server-mates.js +4 -3
  36. package/lib/server-palette.js +17 -14
  37. package/lib/server.js +184 -102
  38. package/lib/session-pair-factory.js +3 -0
  39. package/lib/session-pair-history.js +40 -0
  40. package/lib/session-pair-mcp-server.js +12 -1
  41. package/lib/session-pair-operation.js +11 -0
  42. package/lib/session-pair-prompts.js +24 -2
  43. package/lib/session-pair-turn-control.js +8 -3
  44. package/lib/worker-proposal-control.js +69 -0
  45. package/lib/worker-proposal-decision.js +7 -0
  46. package/lib/worker-runtime-catalog.js +52 -0
  47. package/lib/workspace-query-access.js +3 -2
  48. package/lib/workspace-query-service.js +1 -1
  49. package/lib/worktree.js +8 -4
  50. package/lib/ws-schema.js +7 -0
  51. package/lib/yoke/adapters/codex.js +6 -2
  52. package/package.json +1 -1
@@ -341,11 +341,12 @@ function createProjectLogsStore(opts) {
341
341
  // revision that changes category updates the vocabulary without rewriting a
342
342
  // single historical record. A shared project shares this list because it
343
343
  // shares this store.
344
- function categories() {
344
+ function categories(options) {
345
345
  var seen = {};
346
346
  var out = [];
347
347
  var live = entries(false);
348
348
  for (var i = 0; i < live.length; i++) {
349
+ if (!logsQuery.matchesContext(live[i], options || {})) continue;
349
350
  var value = live[i].category;
350
351
  if (!value || seen[value]) continue;
351
352
  seen[value] = true;
@@ -382,6 +383,7 @@ function createProjectLogsStore(opts) {
382
383
  var out = [];
383
384
  var total = 0;
384
385
  for (var i = 0; i < live.length; i++) {
386
+ if (!logsQuery.matchesContext(live[i], options2)) continue;
385
387
  var awaiting = logsComments.pendingReviewComments(live[i].comments);
386
388
  for (var j = 0; j < awaiting.length; j++) {
387
389
  total++;
@@ -408,13 +410,13 @@ function createProjectLogsStore(opts) {
408
410
  function list(args) {
409
411
  var options2 = args || {};
410
412
  var result = logsQuery.listEntries(entries(options2.includeDeleted === true), options2, cleanLine, validateKind);
411
- result.categories = categories();
413
+ result.categories = categories(options2);
412
414
  return result;
413
415
  }
414
416
 
415
417
  function search(args) {
416
418
  var result = logsQuery.searchEntries(entries(false), args || {}, cleanLine, validateKind);
417
- result.categories = categories();
419
+ result.categories = categories(args || {});
418
420
  return result;
419
421
  }
420
422
 
@@ -14,6 +14,10 @@
14
14
  // into, and inventing a destructive one would lose work.
15
15
 
16
16
  var eligibility = require("./session-driver-eligibility");
17
+ var replacementState = require("./project-pair-replacement-state");
18
+ var pairUsage = require("./project-pair-usage");
19
+ var contextStatus = pairUsage.contextStatus;
20
+ var firstNumber = pairUsage.firstNumber;
17
21
 
18
22
  var MAX_GENERATIONS = 5;
19
23
  var MAX_NOTE_CHARS = 400;
@@ -38,58 +42,10 @@ function clampText(value, max) {
38
42
  return text;
39
43
  }
40
44
 
41
- function firstNumber() {
42
- for (var i = 0; i < arguments.length; i++) {
43
- var value = arguments[i];
44
- if (typeof value === "number" && isFinite(value) && value >= 0) return value;
45
- }
46
- return null;
47
- }
48
-
49
- // The Worker's context position, from authoritative session accounting only:
50
- // the SDK's own getContextUsage snapshot when the adapter provides one, else
51
- // the last result message's usage totals. Field names differ across adapters,
52
- // so each candidate is probed and `source` states which reading was used.
53
- // Never estimated from history text.
54
- function contextStatus(session) {
55
- var snapshot = session.lastContextUsage || null;
56
- var used = null;
57
- var window = null;
58
- var source = "unavailable";
59
-
60
- if (snapshot && typeof snapshot === "object") {
61
- used = firstNumber(snapshot.totalTokens, snapshot.usedTokens, snapshot.tokens, snapshot.inputTokens);
62
- window = firstNumber(snapshot.contextWindow, snapshot.maxTokens, snapshot.windowSize, snapshot.limit);
63
- if (used !== null || window !== null) source = "sdk_context_usage";
64
- }
65
-
66
- if (used === null) {
67
- var history = session.history || [];
68
- for (var i = history.length - 1; i >= 0; i--) {
69
- if (!history[i] || history[i].type !== "result") continue;
70
- var usage = history[i].usage || null;
71
- if (!usage) break;
72
- var input = firstNumber(usage.input_tokens, usage.inputTokens) || 0;
73
- var cacheRead = firstNumber(usage.cache_read_input_tokens, usage.cacheReadInputTokens) || 0;
74
- var cacheWrite = firstNumber(usage.cache_creation_input_tokens, usage.cacheCreationInputTokens) || 0;
75
- var output = firstNumber(usage.output_tokens, usage.outputTokens) || 0;
76
- used = input + cacheRead + cacheWrite + output;
77
- source = "last_result_usage";
78
- break;
79
- }
80
- }
81
-
82
- var ratio = null;
83
- if (used !== null && window !== null && window > 0) {
84
- ratio = Math.round((used / window) * 1000) / 1000;
85
- if (ratio > 1) ratio = 1;
86
- }
87
- return {
88
- source: source,
89
- usedTokens: used,
90
- contextWindow: window,
91
- usedRatio: ratio,
92
- };
45
+ function replacementFingerprint(args, worker) {
46
+ return JSON.stringify({ sourceWorkerId: worker.localId, interrupt: args.interrupt === true,
47
+ vendor: args.workerVendor || "", model: args.workerModel || "", effort: args.workerEffort || "",
48
+ message: args.message || "", evaluation: args.evaluation || null });
93
49
  }
94
50
 
95
51
  function continuityStatus(session) {
@@ -119,6 +75,8 @@ function activityStatus(session) {
119
75
  isProcessing: !!(session.isProcessing || session._queryStarting),
120
76
  delegated: !!token,
121
77
  currentTask: token ? clampText(token.message, MAX_TASK_PREVIEW_CHARS) : "",
78
+ currentTaskId: token && token.taskId || null,
79
+ interruption: session._pairInterruption || null,
122
80
  lastTurnInterrupted: !!session._lastTurnInterrupted,
123
81
  };
124
82
  }
@@ -177,8 +135,8 @@ function attachPairLifecycle(ctx) {
177
135
  userTurns: continuity.userTurns,
178
136
  errorEntries: continuity.errorEntries,
179
137
  interrupted: !!worker._lastTurnInterrupted,
180
- usedTokens: context.usedTokens,
181
- usedRatio: context.usedRatio,
138
+ usedTokens: context.current.usedTokens,
139
+ usedRatio: context.current.usedRatio,
182
140
  };
183
141
  return record;
184
142
  }
@@ -217,6 +175,7 @@ function attachPairLifecycle(ctx) {
217
175
  var resolved = resolveDriverPair(caller);
218
176
  var worker = resolved.worker;
219
177
  var blocked = turnControl.blockedReason(caller) || replaceBlockedReason(worker);
178
+ var resumeBlocked = turnControl.blockedReason(caller) || (worker.isProcessing || worker._queryStarting ? "the Split Worker is mid-turn" : (!worker._pairInterruption ? "the Split Worker has no interrupted task" : null));
220
179
  var ledger = ledgerFor(caller).map(function (record) {
221
180
  return {
222
181
  generation: record.generation,
@@ -228,6 +187,23 @@ function attachPairLifecycle(ctx) {
228
187
  };
229
188
  });
230
189
  return {
190
+ identity: {
191
+ sessionId: worker.localId,
192
+ originId: worker.sessionOriginId || null,
193
+ kind: worker.sessionProvenance && worker.sessionProvenance.kind || "worker",
194
+ generation: worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation || null,
195
+ },
196
+ configuration: {
197
+ vendor: worker.vendor || null,
198
+ model: worker.model || null,
199
+ effort: worker.effort || null,
200
+ permissionMode: worker.permissionMode || null,
201
+ },
202
+ time: {
203
+ observedAt: Date.now(),
204
+ createdAt: worker.sessionProvenance && worker.sessionProvenance.createdAt || null,
205
+ lastActivityAt: firstNumber(worker.lastActivityAt, worker.lastActivity),
206
+ },
231
207
  worker: {
232
208
  sessionId: worker.localId,
233
209
  title: worker.title || "New Session",
@@ -242,6 +218,13 @@ function attachPairLifecycle(ctx) {
242
218
  orchestration: turnControl.status(caller),
243
219
  replaceSafe: !blocked,
244
220
  replaceBlockedReason: blocked,
221
+ canReplace: !blocked,
222
+ canReplaceBlockedReason: blocked,
223
+ canResume: !resumeBlocked,
224
+ resumeBlockedReason: resumeBlocked,
225
+ tasks: ctx.taskStatus ? ctx.taskStatus(worker) : null,
226
+ replacement: caller._lastPairReplacement || null,
227
+ proposal: ctx.proposalStatus ? ctx.proposalStatus(caller) : null,
245
228
  generations: ledger,
246
229
  };
247
230
  }
@@ -256,45 +239,67 @@ function attachPairLifecycle(ctx) {
256
239
  var group = resolved.group;
257
240
  var oldWorker = resolved.worker;
258
241
  turnControl.assertWorkerAction(caller);
242
+ var replacementEntry = replacementState.begin(caller, args.transactionId, oldWorker, replacementFingerprint(args, oldWorker));
243
+ var replay = replacementState.replayValue(replacementEntry);
244
+ if (replay) return Promise.resolve(replay);
259
245
 
260
246
  // Validate the replacement while the old pair is still fully intact. An
261
247
  // uninstalled vendor, an unavailable model or an unsupported effort must
262
248
  // never cost the user their running Worker, so this precedes the interrupt,
263
249
  // the permission cancellation and the dissolve.
264
- ctx.preflightWorkerForDriver(caller, {
265
- workerVendor: args.workerVendor,
266
- workerModel: args.workerModel,
267
- workerEffort: args.workerEffort,
268
- });
250
+ try {
251
+ ctx.preflightWorkerForDriver(caller, {
252
+ workerVendor: args.workerVendor,
253
+ workerModel: args.workerModel,
254
+ workerEffort: args.workerEffort,
255
+ });
256
+ } catch (preflightError) {
257
+ replacementState.fail(replacementEntry.transaction, "preflight_failed", preflightError, "not_required");
258
+ throw preflightError;
259
+ }
269
260
 
270
261
  var blocked = replaceBlockedReason(oldWorker);
271
262
  if (blocked && args.interrupt !== true) {
263
+ replacementState.fail(replacementEntry.transaction, "preflight_failed", new Error(blocked), "not_required");
272
264
  throw new Error("cannot replace the Split Worker because " + blocked +
273
265
  "; call again with interrupt set to true to stop it first");
274
266
  }
275
267
 
276
- if (args.evaluation) validateEvaluation(args.evaluation);
277
- var creationTicket = turnControl.reserveCreation(caller, "replace");
268
+ var creationTicket;
269
+ try {
270
+ if (args.evaluation) validateEvaluation(args.evaluation);
271
+ creationTicket = turnControl.reserveCreation(caller, "replace");
272
+ } catch (validationError) {
273
+ replacementState.fail(replacementEntry.transaction, "preflight_failed", validationError, "not_required");
274
+ throw validationError;
275
+ }
278
276
 
279
277
  if (blocked) {
278
+ replacementState.stage(replacementEntry.transaction, "stopping_source");
279
+ replacementEntry.transaction.sourceStopped = true;
280
+ if (ctx.markInterruption) ctx.markInterruption(oldWorker, "driver", "The Driver accepted replacement of this Worker generation.");
280
281
  oldWorker.taskStopRequested = true;
281
282
  if (oldWorker.abortController) {
282
283
  try { oldWorker.abortController.abort(); } catch (e) {}
283
284
  }
284
285
  if (oldWorker._pairDelegation && typeof ctx.finishDelegation === "function") {
286
+ if (typeof ctx.completeInterruptedTask === "function") ctx.completeInterruptedTask(oldWorker, caller, oldWorker._pairDelegation);
285
287
  ctx.finishDelegation(group, caller, oldWorker, oldWorker._pairDelegation);
286
288
  }
287
289
  }
288
290
 
289
291
  // Any permission decision the old Worker was waiting on dies with the pair.
292
+ replacementState.stage(replacementEntry.transaction, "cancelling_source_waits");
290
293
  if (typeof ctx.cancelWorkerPermissions === "function") {
291
294
  ctx.cancelWorkerPermissions(oldWorker, "The Driver replaced this Split Worker.");
292
295
  }
293
296
 
294
297
  var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
298
+ replacementState.stage(replacementEntry.transaction, "dissolving_source_pair");
295
299
  var dissolved = store.dissolve(ws, { id: group.id });
296
300
  if (!dissolved.ok) {
297
301
  turnControl.releaseCreation(creationTicket);
302
+ replacementState.fail(replacementEntry.transaction, "dissolve_failed", new Error(dissolved.error || "could not dissolve"), "not_required");
298
303
  throw new Error(dissolved.error || "could not dissolve the existing pair");
299
304
  }
300
305
 
@@ -316,6 +321,7 @@ function attachPairLifecycle(ctx) {
316
321
  // come back. The error says so rather than implying a clean restore.
317
322
  var created;
318
323
  try {
324
+ replacementState.stage(replacementEntry.transaction, "creating_target");
319
325
  created = ctx.createWorkerForDriver(caller, {
320
326
  workerVendor: typeof args.workerVendor === "string" ? args.workerVendor : "",
321
327
  workerModel: typeof args.workerModel === "string" ? args.workerModel : "",
@@ -333,6 +339,7 @@ function attachPairLifecycle(ctx) {
333
339
  ? " Its interrupted turn cannot be resumed, because stopping it was explicitly requested."
334
340
  : "";
335
341
  turnControl.releaseCreation(creationTicket);
342
+ replacementState.fail(replacementEntry.transaction, "creation_failed", e, restored && restored.ok ? "source_pair_restored" : "source_pair_restore_failed");
336
343
  throw new Error("could not create the replacement Split Worker: " + (e.message || String(e)) +
337
344
  ". " + restoreNote + interruptNote);
338
345
  }
@@ -344,6 +351,7 @@ function attachPairLifecycle(ctx) {
344
351
  status: "replaced",
345
352
  previousWorkerSessionId: oldWorker.localId,
346
353
  previousWorkerHistoryPreserved: true,
354
+ previousWorkerFilesPreserved: true,
347
355
  previousGeneration: closed ? closed.generation : null,
348
356
  interrupted: !!blocked,
349
357
  workerSessionId: created.worker.localId,
@@ -351,7 +359,9 @@ function attachPairLifecycle(ctx) {
351
359
  vendor: created.worker.vendor || null,
352
360
  model: created.worker.model || null,
353
361
  effort: created.worker.effort || null,
362
+ transactionId: replacementEntry.transaction.transactionId,
354
363
  };
364
+ replacementState.complete(replacementEntry.transaction, result);
355
365
 
356
366
  var message = typeof args.message === "string" ? args.message.trim() : "";
357
367
  if (!message) return Promise.resolve(result);
@@ -0,0 +1,50 @@
1
+ var crypto = require("crypto");
2
+
3
+ function toolResult(value) {
4
+ return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] });
5
+ }
6
+
7
+ function requestId(value) {
8
+ if (typeof value !== "string") return "pmsg_" + crypto.randomUUID();
9
+ var clean = value.trim();
10
+ if (!clean || clean.length > 120 || !/^[A-Za-z0-9._:-]+$/.test(clean)) return null;
11
+ return clean;
12
+ }
13
+
14
+ function attachPairMessage(ctx) {
15
+ function send(args, caller) {
16
+ var id = requestId(args.requestId);
17
+ if (!id) return toolResult({ status: "rejected", requestId: null, reason: "requestId is invalid" });
18
+ try {
19
+ ctx.turnControl.assertWorkerAction(caller);
20
+ var resolved = ctx.resolvePair(caller);
21
+ var worker = resolved.partner;
22
+ var message = typeof args.message === "string" ? args.message.trim() : "";
23
+ if (!message) return toolResult({ status: "rejected", requestId: id, partnerId: worker.localId, reason: "message is required" });
24
+ if (!worker.isProcessing && !worker._queryStarting) {
25
+ return toolResult({ status: "rejected", requestId: id, partnerId: worker.localId, reason: "the Split Worker has no active turn" });
26
+ }
27
+ var sdk = ctx.getSdk();
28
+ if (!sdk || !sdk.pushMessage(worker, message)) {
29
+ return toolResult({ status: "rejected", requestId: id, partnerId: worker.localId, reason: "the active Worker runtime did not accept the message" });
30
+ }
31
+ ctx.sm.sendAndRecord(worker, {
32
+ type: "partner_message",
33
+ text: message,
34
+ requestId: id,
35
+ delegatedBy: caller.localId,
36
+ });
37
+ worker.lastActivity = Date.now();
38
+ return toolResult({ status: "queued", requestId: id, partnerId: worker.localId, acknowledgement: "input_queue", runtimeState: worker._queryStarting ? "starting" : "active" });
39
+ } catch (err) {
40
+ return toolResult({ status: "rejected", requestId: id, reason: err.message || String(err) });
41
+ }
42
+ }
43
+
44
+ function fingerprint(args) {
45
+ return typeof args.message === "string" ? args.message.trim() : "";
46
+ }
47
+ return { fingerprint: fingerprint, send: send };
48
+ }
49
+
50
+ module.exports = { attachPairMessage: attachPairMessage };
@@ -0,0 +1,72 @@
1
+ var crypto = require("crypto");
2
+
3
+ function storeFor(driver) {
4
+ if (!driver._pairReplacementTransactions) driver._pairReplacementTransactions = Object.create(null);
5
+ return driver._pairReplacementTransactions;
6
+ }
7
+
8
+ function begin(driver, requestedId, worker, fingerprint) {
9
+ var id = typeof requestedId === "string" && requestedId.trim() ? requestedId.trim() : "replace_" + crypto.randomUUID();
10
+ var existing = storeFor(driver)[id];
11
+ if (existing) {
12
+ if (existing.fingerprint !== (fingerprint || "")) throw new Error("replacement transaction id was already used with different input");
13
+ return { transaction: existing, replay: true };
14
+ }
15
+ var transaction = {
16
+ transactionId: id,
17
+ status: "active",
18
+ stage: "preflight",
19
+ startedAt: Date.now(),
20
+ updatedAt: Date.now(),
21
+ sourceWorkerId: worker.localId,
22
+ sourceGeneration: worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation || null,
23
+ sourceTurnState: worker.isProcessing || worker._queryStarting ? "running" : "idle",
24
+ sourceStopped: false,
25
+ sourcePairRestored: null,
26
+ filesPreserved: true,
27
+ fingerprint: fingerprint || "",
28
+ targetWorkerId: null,
29
+ targetGeneration: null,
30
+ failure: null,
31
+ result: null,
32
+ };
33
+ storeFor(driver)[id] = transaction;
34
+ driver._lastPairReplacement = transaction;
35
+ return { transaction: transaction, replay: false };
36
+ }
37
+
38
+ function stage(transaction, value) {
39
+ transaction.stage = value;
40
+ transaction.updatedAt = Date.now();
41
+ return transaction;
42
+ }
43
+
44
+ function fail(transaction, stageName, error, rollback) {
45
+ transaction.status = "failed";
46
+ transaction.stage = stageName;
47
+ transaction.updatedAt = Date.now();
48
+ transaction.completedAt = transaction.updatedAt;
49
+ transaction.failure = { message: error && (error.message || String(error)) || "Unknown replacement failure", rollback: rollback || "not_required" };
50
+ transaction.sourcePairRestored = rollback === "source_pair_restored" ? true : (rollback === "source_pair_restore_failed" ? false : null);
51
+ return transaction;
52
+ }
53
+
54
+ function complete(transaction, result) {
55
+ transaction.status = "completed";
56
+ transaction.stage = "committed";
57
+ transaction.updatedAt = Date.now();
58
+ transaction.completedAt = transaction.updatedAt;
59
+ transaction.targetWorkerId = result.workerSessionId;
60
+ transaction.targetGeneration = result.generation;
61
+ transaction.result = result;
62
+ return transaction;
63
+ }
64
+
65
+ function replayValue(entry) {
66
+ if (!entry.replay) return null;
67
+ if (entry.transaction.status === "completed") return entry.transaction.result;
68
+ if (entry.transaction.status === "failed") throw new Error(entry.transaction.failure.message);
69
+ throw new Error("replacement transaction is already active at stage " + entry.transaction.stage);
70
+ }
71
+
72
+ module.exports = { begin: begin, complete: complete, fail: fail, replayValue: replayValue, stage: stage };
@@ -0,0 +1,264 @@
1
+ var crypto = require("crypto");
2
+ var buildShape = require("./session-spawn-mcp-server").buildShape;
3
+
4
+ var OUTCOMES = ["completed", "partial", "failed", "interrupted"];
5
+ var MAX_QUEUE = 20;
6
+ var MAX_ITEMS = 50;
7
+ var MAX_TEXT = 1000;
8
+
9
+ function result(value) { return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] }); }
10
+ function cleanText(value, max) {
11
+ var text = typeof value === "string" ? value.trim() : "";
12
+ return text.length > max ? text.slice(0, max) : text;
13
+ }
14
+ function cleanId(value, prefix) {
15
+ if (value === undefined || value === null || value === "") return prefix + crypto.randomUUID();
16
+ if (typeof value !== "string") return null;
17
+ var id = value.trim();
18
+ return id && id.length <= 120 && /^[A-Za-z0-9._:-]+$/.test(id) ? id : null;
19
+ }
20
+ function cleanStrings(values) {
21
+ if (!Array.isArray(values)) return null;
22
+ return values.slice(0, MAX_ITEMS).map(function (value) { return cleanText(value, MAX_TEXT); }).filter(Boolean);
23
+ }
24
+ function cleanVerifications(values) {
25
+ if (!Array.isArray(values)) return null;
26
+ return values.slice(0, MAX_ITEMS).map(function (value) {
27
+ if (typeof value === "string") return { command: cleanText(value, MAX_TEXT), result: "unknown" };
28
+ if (!value || typeof value !== "object") return null;
29
+ return { command: cleanText(value.command, MAX_TEXT), result: cleanText(value.result, MAX_TEXT) || "unknown" };
30
+ }).filter(Boolean);
31
+ }
32
+
33
+ function attachPairTaskControl(ctx) {
34
+ function queueFor(worker) {
35
+ if (!Array.isArray(worker._pairFollowups)) worker._pairFollowups = [];
36
+ return worker._pairFollowups;
37
+ }
38
+ function registryFor(worker) {
39
+ if (!worker._pairTaskIds) worker._pairTaskIds = Object.create(null);
40
+ return worker._pairTaskIds;
41
+ }
42
+ function current(worker) { return worker && worker._pairDelegation || null; }
43
+ function taskGeneration(worker) {
44
+ return worker._pairGeneration || worker.sessionProvenance && worker.sessionProvenance.generation || null;
45
+ }
46
+ function findTask(worker, taskId) {
47
+ var active = current(worker);
48
+ if (active && active.taskId === taskId) return active;
49
+ var queue = queueFor(worker);
50
+ for (var i = 0; i < queue.length; i++) if (queue[i].taskId === taskId) return queue[i];
51
+ return null;
52
+ }
53
+ function begin(worker, caller, message, requestedId) {
54
+ var taskId = cleanId(requestedId, "ptask_");
55
+ if (!taskId) throw new Error("taskId is invalid");
56
+ var existing = registryFor(worker)[taskId];
57
+ if (existing && existing.status === "starting" && existing.workerSessionId === worker.localId &&
58
+ existing.driverSessionId === caller.localId && existing.message === message) {
59
+ existing.status = "running";
60
+ existing.startedAt = existing.startedAt || Date.now();
61
+ return existing;
62
+ }
63
+ if (findTask(worker, taskId) || existing) throw new Error("taskId is already in use for this Worker generation");
64
+ var task = {
65
+ taskId: taskId,
66
+ generation: taskGeneration(worker),
67
+ workerSessionId: worker.localId,
68
+ driverSessionId: caller.localId,
69
+ message: message,
70
+ createdAt: Date.now(),
71
+ status: "running",
72
+ };
73
+ registryFor(worker)[taskId] = task;
74
+ return task;
75
+ }
76
+ function projectTask(task) {
77
+ return {
78
+ taskId: task.taskId,
79
+ generation: task.generation,
80
+ workerSessionId: task.workerSessionId,
81
+ status: task.status,
82
+ createdAt: task.createdAt,
83
+ startedAt: task.startedAt || null,
84
+ replacesTaskId: task.replacesTaskId || null,
85
+ message: cleanText(task.message, MAX_TEXT),
86
+ };
87
+ }
88
+ function queue(args, caller) {
89
+ try {
90
+ ctx.turnControl.assertWorkerAction(caller);
91
+ var resolved = ctx.resolvePair(caller);
92
+ var worker = resolved.partner;
93
+ var message = cleanText(args.message, 30000);
94
+ if (!message) throw new Error("message is required");
95
+ if (!worker.isProcessing && !worker._queryStarting && !current(worker)) throw new Error("the Split Worker is idle; use send_to_partner for an immediate follow-up");
96
+ var task = begin(worker, caller, message, args.taskId);
97
+ task.status = "queued";
98
+ var items = queueFor(worker);
99
+ if (items.filter(function (item) { return item.status === "queued" || item.status === "waiting_for_interrupt"; }).length >= MAX_QUEUE) throw new Error("the Split Worker follow-up queue is full");
100
+ items.push(task);
101
+ return result({ status: "queued", task: projectTask(task) });
102
+ } catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
103
+ }
104
+ function inspect(args, caller) {
105
+ try {
106
+ var worker = ctx.resolvePair(caller).partner;
107
+ return result(status(worker));
108
+ } catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
109
+ }
110
+ function cancel(args, caller) {
111
+ try {
112
+ ctx.turnControl.assertWorkerAction(caller);
113
+ var worker = ctx.resolvePair(caller).partner;
114
+ var task = findTask(worker, args.taskId);
115
+ if (!task || (task.status !== "queued" && task.status !== "waiting_for_interrupt")) throw new Error("the exact queued task is not cancellable");
116
+ task.status = "cancelled";
117
+ task.completedAt = Date.now();
118
+ return result({ status: "cancelled", taskId: task.taskId });
119
+ } catch (err) { return result({ status: "rejected", taskId: args.taskId || null, reason: err.message || String(err) }); }
120
+ }
121
+ function markInterruption(worker, by, reason, targetTaskId) {
122
+ var active = current(worker);
123
+ worker._pairInterruption = {
124
+ interruptedBy: by || "system",
125
+ reason: cleanText(reason, MAX_TEXT) || "No reason supplied.",
126
+ targetTaskId: targetTaskId || active && active.taskId || null,
127
+ requestedAt: Date.now(),
128
+ completedAt: null,
129
+ };
130
+ return worker._pairInterruption;
131
+ }
132
+ function ensureSystemInterruption(worker, token) {
133
+ if (worker._lastTurnInterrupted && (!worker._pairInterruption || worker._pairInterruption.targetTaskId !== token.taskId)) {
134
+ markInterruption(worker, "system", "The Worker runtime ended this task as interrupted.", token.taskId);
135
+ }
136
+ }
137
+ function replaceTask(args, caller) {
138
+ try {
139
+ ctx.turnControl.assertWorkerAction(caller);
140
+ var resolved = ctx.resolvePair(caller);
141
+ var worker = resolved.partner;
142
+ var active = current(worker);
143
+ if (!active || !worker.isProcessing) throw new Error("there is no active delegated task to replace");
144
+ if (!args.targetTaskId || args.targetTaskId !== active.taskId) throw new Error("targetTaskId must match the exact active task");
145
+ var message = cleanText(args.message, 30000);
146
+ if (!message) throw new Error("message is required");
147
+ var replacement = begin(worker, caller, message, args.taskId);
148
+ replacement.status = "waiting_for_interrupt";
149
+ replacement.replacesTaskId = active.taskId;
150
+ queueFor(worker).push(replacement);
151
+ markInterruption(worker, "driver", args.reason || "The Driver replaced the active task.", active.taskId);
152
+ worker.taskStopRequested = true;
153
+ if (worker.abortController) worker.abortController.abort();
154
+ return result({ status: "interrupting", targetTaskId: active.taskId, replacement: projectTask(replacement) });
155
+ } catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
156
+ }
157
+ function resumeTask(args, caller) {
158
+ try {
159
+ ctx.turnControl.assertWorkerAction(caller);
160
+ var worker = ctx.resolvePair(caller).partner;
161
+ if (worker.isProcessing || worker._queryStarting || current(worker)) throw new Error("the Split Worker is not idle");
162
+ var prior = worker._lastInterruptedPairTask;
163
+ if (!prior) throw new Error("the Split Worker has no resumable interrupted task");
164
+ var message = cleanText(args.message, 30000) || prior.message;
165
+ return ctx.sendFollowup({ message: message, wait: args.wait, timeoutSeconds: args.timeoutSeconds, taskId: args.taskId }, caller);
166
+ } catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
167
+ }
168
+ function report(args, worker) {
169
+ try {
170
+ if (!worker || ctx.sm.sessions.get(worker.localId) !== worker) throw new Error("the Worker session is no longer live");
171
+ var resolved = ctx.rolesFor(worker);
172
+ if (!resolved || resolved.worker !== worker) throw new Error("only the exact configured Split Worker can report an outcome");
173
+ var active = current(worker);
174
+ if (!active || args.taskId !== active.taskId) throw new Error("taskId must match the exact active delegated task");
175
+ var outcome = typeof args.outcome === "string" ? args.outcome.trim().toLowerCase() : "";
176
+ if (OUTCOMES.indexOf(outcome) === -1) throw new Error("outcome must be completed, partial, failed, or interrupted");
177
+ active.reportedOutcome = {
178
+ outcome: outcome,
179
+ summary: cleanText(args.summary, MAX_TEXT) || null,
180
+ changedFiles: cleanStrings(args.changedFiles),
181
+ verifications: cleanVerifications(args.verifications),
182
+ unverified: cleanStrings(args.unverified),
183
+ processes: cleanStrings(args.processes),
184
+ nextAction: cleanText(args.nextAction, MAX_TEXT) || null,
185
+ reportedAt: Date.now(),
186
+ };
187
+ return result({ status: "recorded", taskId: active.taskId });
188
+ } catch (err) { return result({ status: "rejected", reason: err.message || String(err) }); }
189
+ }
190
+ function complete(worker, caller, token, observedStatus, response, error) {
191
+ if (!token) return null;
192
+ if (token.outcomeEnvelope) return token.outcomeEnvelope;
193
+ var interruption = worker._pairInterruption && worker._pairInterruption.targetTaskId === token.taskId ? worker._pairInterruption : null;
194
+ if (interruption) interruption.completedAt = Date.now();
195
+ var report = token.reportedOutcome || null;
196
+ var finalStatus = error ? "failed" : (interruption ? "interrupted" : (report ? report.outcome : observedStatus));
197
+ var envelope = {
198
+ taskId: token.taskId,
199
+ generation: token.generation,
200
+ workerSessionId: worker.localId,
201
+ status: finalStatus,
202
+ completedAt: Date.now(),
203
+ interruption: interruption,
204
+ workerReport: report,
205
+ changedFiles: report ? report.changedFiles : null,
206
+ verifications: report ? report.verifications : null,
207
+ unverified: report ? report.unverified : null,
208
+ processes: report ? report.processes : null,
209
+ nextAction: report ? report.nextAction : null,
210
+ verificationAuthority: report ? "worker_reported" : "unknown",
211
+ response: response || "",
212
+ error: error || null,
213
+ };
214
+ token.status = finalStatus;
215
+ token.outcomeEnvelope = envelope;
216
+ worker._lastPairOutcome = envelope;
217
+ if (finalStatus === "interrupted" || finalStatus === "partial") worker._lastInterruptedPairTask = { taskId: token.taskId, message: token.message, generation: token.generation };
218
+ if (ctx.sm.sendToSession) ctx.sm.sendToSession(caller, { type: "partner_task_completed", outcome: envelope });
219
+ return envelope;
220
+ }
221
+ function status(worker) {
222
+ var active = current(worker);
223
+ var registry = registryFor(worker);
224
+ var ids = Object.keys(registry);
225
+ return {
226
+ status: "ok",
227
+ current: active ? projectTask(active) : null,
228
+ queued: queueFor(worker).filter(function (item) { return item.status === "queued" || item.status === "waiting_for_interrupt"; }).map(projectTask),
229
+ interruption: worker._pairInterruption || null,
230
+ lastOutcome: worker._lastPairOutcome || null,
231
+ recent: ids.slice(-20).map(function (id) { return projectTask(registry[id]); }),
232
+ };
233
+ }
234
+ function drain(caller, worker) {
235
+ if (ctx.turnControl.blockedReason(caller) || worker.isProcessing || worker._queryStarting || current(worker)) return false;
236
+ var queue = queueFor(worker);
237
+ var next = null;
238
+ for (var i = 0; i < queue.length; i++) {
239
+ if (queue[i].status === "queued" || queue[i].status === "waiting_for_interrupt") { next = queue[i]; break; }
240
+ }
241
+ if (!next) return false;
242
+ next.status = "starting";
243
+ next.startedAt = Date.now();
244
+ queue.splice(queue.indexOf(next), 1);
245
+ Promise.resolve(ctx.sendFollowup({ message: next.message, wait: false, taskId: next.taskId }, caller)).then(function (delivery) {
246
+ next.status = delivery && delivery.isError ? "failed" : "running";
247
+ if (delivery && delivery.isError) next.error = delivery.content && delivery.content[0] && delivery.content[0].text || "Follow-up rejected";
248
+ }, function (err) { next.status = "failed"; next.error = err.message || String(err); });
249
+ return true;
250
+ }
251
+ function driverToolDefs(handlers) {
252
+ return [{ name: "queue_partner_followup", description: "Queue a distinct follow-up task behind the active Split Worker task.", inputSchema: buildShape({ message: { type: "string" }, taskId: { type: "string" } }, ["message"]), handler: handlers.queue },
253
+ { name: "inspect_partner_followups", description: "Inspect current, queued, interrupted, and last completed Split Worker task state.", inputSchema: buildShape({}), handler: handlers.inspect },
254
+ { name: "cancel_partner_followup", description: "Cancel one exact queued follow-up without interrupting active work.", inputSchema: buildShape({ taskId: { type: "string" } }, ["taskId"]), handler: handlers.cancel },
255
+ { name: "replace_partner_task", description: "Interrupt one exact active delegated task and queue a replacement task in the same Worker generation.", inputSchema: buildShape({ targetTaskId: { type: "string" }, message: { type: "string" }, taskId: { type: "string" }, reason: { type: "string" } }, ["targetTaskId", "message"]), handler: handlers.replace },
256
+ { name: "resume_partner_task", description: "Resume the last interrupted task as a new correlated task. A human Stop blocks this until a new user turn.", inputSchema: buildShape({ message: { type: "string" }, taskId: { type: "string" }, wait: { type: "boolean" }, timeoutSeconds: { type: "number" } }), handler: handlers.resume }];
257
+ }
258
+ function workerToolDefs(worker) {
259
+ return [{ name: "report_partner_outcome", description: "Report structured, Worker-observed task outcome data. This does not claim Driver verification.", inputSchema: buildShape({ taskId: { type: "string" }, outcome: { type: "string" }, summary: { type: "string" }, changedFiles: { type: "array", items: { type: "string" } }, verifications: { type: "array", items: { type: "object" } }, unverified: { type: "array", items: { type: "string" } }, processes: { type: "array", items: { type: "string" } }, nextAction: { type: "string" } }, ["taskId", "outcome"]), handler: function (args) { return report(args || {}, worker); } }];
260
+ }
261
+ return { begin: begin, cancel: cancel, complete: complete, drain: drain, driverToolDefs: driverToolDefs, ensureSystemInterruption: ensureSystemInterruption, inspect: inspect, markInterruption: markInterruption, queue: queue, replaceTask: replaceTask, resumeTask: resumeTask, status: status, workerToolDefs: workerToolDefs };
262
+ }
263
+
264
+ module.exports = { attachPairTaskControl: attachPairTaskControl };