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
@@ -0,0 +1,73 @@
1
+ function firstNumber() {
2
+ for (var i = 0; i < arguments.length; i++) {
3
+ var value = arguments[i];
4
+ if (typeof value === "number" && isFinite(value) && value >= 0) return value;
5
+ }
6
+ return null;
7
+ }
8
+
9
+ function matchingWindow(modelUsage, model) {
10
+ if (!modelUsage || !model || !modelUsage[model]) return null;
11
+ return firstNumber(modelUsage[model].contextWindow);
12
+ }
13
+
14
+ function contextStatus(session) {
15
+ var snapshot = session.lastContextUsage || null;
16
+ var used = null;
17
+ var window = null;
18
+ var source = "unavailable";
19
+ if (snapshot && typeof snapshot === "object") {
20
+ if (session.vendor === "claude") {
21
+ used = firstNumber(snapshot.totalTokens);
22
+ window = firstNumber(snapshot.maxTokens);
23
+ if (used !== null || window !== null) source = "claude_sdk_context_usage";
24
+ } else {
25
+ used = firstNumber(snapshot.input_tokens);
26
+ window = firstNumber(snapshot.contextWindow);
27
+ if (used !== null || window !== null) source = "adapter_context_usage";
28
+ }
29
+ }
30
+ var history = session.history || [];
31
+ var cumulativeInput = 0;
32
+ var cumulativeOutput = 0;
33
+ var results = 0;
34
+ var observedInput = false;
35
+ var observedOutput = false;
36
+ var lastTask = null;
37
+ var compactions = 0;
38
+ var compacting = false;
39
+ var lastCompactionAt = null;
40
+ for (var i = 0; i < history.length; i++) {
41
+ var item = history[i];
42
+ if (!item) continue;
43
+ if (item.type === "compacting") {
44
+ if (item.active) compactions++;
45
+ compacting = !!item.active;
46
+ if (typeof item._ts === "number") lastCompactionAt = item._ts;
47
+ }
48
+ if (item.type !== "result") continue;
49
+ var usage = item.usage || {};
50
+ var input = firstNumber(usage.input_tokens, usage.inputTokens);
51
+ var output = firstNumber(usage.output_tokens, usage.outputTokens);
52
+ if (input !== null) { cumulativeInput += input; observedInput = true; }
53
+ if (output !== null) { cumulativeOutput += output; observedOutput = true; }
54
+ results++;
55
+ lastTask = { inputTokens: input, outputTokens: output, currentInputTokens: firstNumber(item.lastStreamInputTokens) };
56
+ var observedWindow = matchingWindow(item.modelUsage, session.model);
57
+ if (observedWindow !== null) window = observedWindow;
58
+ }
59
+ if (used === null && lastTask && lastTask.currentInputTokens !== null) {
60
+ used = lastTask.currentInputTokens;
61
+ source = "last_stream_input";
62
+ }
63
+ var ratio = used !== null && window !== null && window > 0 ? Math.min(1, Math.round((used / window) * 1000) / 1000) : null;
64
+ return {
65
+ current: { source: source, usedTokens: used, windowTokens: window, usedRatio: ratio,
66
+ scope: used === null && window === null ? "unknown" : (used !== null && window !== null ? "current_context" : "partial_current_context") },
67
+ cumulative: { inputTokens: observedInput ? cumulativeInput : null, outputTokens: observedOutput ? cumulativeOutput : null, tasksObserved: results, scope: "loaded_session_history", complete: false },
68
+ lastTask: lastTask,
69
+ compactions: { observedCount: compactions, active: compacting, lastObservedAt: lastCompactionAt, scope: "loaded_session_history", complete: false },
70
+ };
71
+ }
72
+
73
+ module.exports = { contextStatus: contextStatus, firstNumber: firstNumber };
@@ -1,69 +1,24 @@
1
- var pairMcp = require("./session-pair-mcp-server");
2
- var pairPrompts = require("./session-pair-prompts");
3
- var { attachPairFactory } = require("./session-pair-factory");
4
- var { attachPairLifecycle } = require("./project-pair-lifecycle");
5
- var { attachPairTurnControl } = require("./session-pair-turn-control");
1
+ var pairMcp = require("./session-pair-mcp-server"), pairPrompts = require("./session-pair-prompts");
2
+ var { attachPairFactory } = require("./session-pair-factory"), { attachPairLifecycle } = require("./project-pair-lifecycle"),
3
+ { attachPairTurnControl } = require("./session-pair-turn-control");
6
4
  var driverEligibility = require("./session-driver-eligibility"), driverOrchestration = require("./session-driver-orchestration");
7
- var { attachWorkerPermission } = require("./project-worker-permission");
8
- var { attachWorkerProposal } = require("./project-worker-proposal");
9
- var MAX_RESPONSE_CHARS = 30000;
5
+ var { attachWorkerPermission } = require("./project-worker-permission"), { attachWorkerProposal } = require("./project-worker-proposal");
6
+ var { attachPairMessage } = require("./project-pair-message"), { attachPairTaskControl } = require("./project-pair-task-control");
7
+ var pairHistory = require("./session-pair-history"), responseText = pairHistory.responseText, errorSince = pairHistory.errorSince, recentTurns = pairHistory.recentTurns, operationFingerprint = require("./session-pair-operation").fingerprint;
10
8
  function toolResult(value) { return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(value) }] }); }
11
9
  function toolError(err) {
12
- return Promise.resolve({
13
- content: [{ type: "text", text: "Error: " + (err.message || err) }],
14
- isError: true,
15
- });
16
- }
17
- function responseText(history, fromIndex) {
18
- var text = "";
19
- for (var i = Math.max(0, fromIndex || 0); i < history.length; i++) {
20
- if (history[i] && history[i].type === "delta" && history[i].text) text += history[i].text;
21
- }
22
- if (text.length > MAX_RESPONSE_CHARS) text = text.slice(-MAX_RESPONSE_CHARS);
23
- return text;
10
+ return Promise.resolve({ content: [{ type: "text", text: "Error: " + (err.message || err) }], isError: true });
24
11
  }
25
-
26
- function errorSince(history, fromIndex) {
27
- for (var i = history.length - 1; i >= Math.max(0, fromIndex || 0); i--) {
28
- if (history[i] && history[i].type === "error") return history[i].text || "partner turn failed";
29
- }
30
- return null;
31
- }
32
-
33
- function recentTurns(session, count) {
34
- var history = session.history || [];
35
- var starts = [];
36
- for (var i = 0; i < history.length; i++) {
37
- if (history[i] && history[i].type === "user_message") starts.push(i);
38
- }
39
- var from = starts.length > 0 ? starts[Math.max(0, starts.length - count)] : 0;
40
- var turns = [];
41
- var current = null;
42
- for (var j = from; j < history.length; j++) {
43
- var item = history[j];
44
- if (!item) continue;
45
- if (item.type === "user_message") {
46
- current = { user: item.text || "", delegated: !!item.delegated, response: "" };
47
- turns.push(current);
48
- } else if (item.type === "delta" && item.text) {
49
- if (!current) { current = { user: "", delegated: false, response: "" }; turns.push(current); }
50
- current.response += item.text;
51
- if (current.response.length > MAX_RESPONSE_CHARS) current.response = current.response.slice(-MAX_RESPONSE_CHARS);
52
- }
53
- }
54
- return turns;
55
- }
56
-
57
12
  function attachSessionPair(ctx) {
58
13
  var sm = ctx.sm;
59
14
  var store = ctx.splitStore;
60
15
  var workerProposal;
61
16
  var workerPermission;
62
17
  var lifecycle;
18
+ var taskControl;
63
19
  var turnControl = attachPairTurnControl({ sm: sm, splitStore: store });
64
20
  function groupAndPartner(caller, missingPairMessage) {
65
21
  if (!caller) throw new Error("partner tools require a session-bound tool server");
66
- // Captured handlers must remain bound to the exact live Session object.
67
22
  if (sm.sessions.get(caller.localId) !== caller) {
68
23
  throw new Error("this session is no longer live; the partner tools are bound to an exact session");
69
24
  }
@@ -78,26 +33,42 @@ function attachSessionPair(ctx) {
78
33
  if (caller.ownerId !== partner.ownerId) throw new Error("split partner access denied");
79
34
  return { group: group, partner: partner };
80
35
  }
81
-
36
+ function rolesFor(session) {
37
+ if (!session || sm.sessions.get(session.localId) !== session) return null;
38
+ var group = store.groupForMember(session.localId);
39
+ if (!group || !group.pair) return null;
40
+ var driver = sm.sessions.get(group.pair.driverId);
41
+ var worker = sm.sessions.get(group.pair.workerId);
42
+ if (!driver || !worker || driver.ownerId !== worker.ownerId) return null;
43
+ return { group: group, driver: driver, worker: worker };
44
+ }
45
+ var pairMessage = attachPairMessage({
46
+ sm: sm,
47
+ getSdk: ctx.getSdk,
48
+ resolvePair: groupAndPartner,
49
+ turnControl: turnControl,
50
+ });
51
+ taskControl = attachPairTaskControl({
52
+ sm: sm, resolvePair: groupAndPartner, rolesFor: rolesFor, turnControl: turnControl,
53
+ sendFollowup: function (args, caller) { return sendToPartner(args, caller); },
54
+ });
82
55
  function broadcastDelegation(group, caller, partner, active) {
83
- var message = {
84
- type: "split_delegation",
85
- groupId: group.id,
86
- from: caller.localId,
87
- to: partner.localId,
88
- active: !!active,
89
- };
56
+ var message = { type: "split_delegation", groupId: group.id, from: caller.localId,
57
+ to: partner.localId, active: !!active };
90
58
  if (typeof ctx.broadcastDelegation === "function") ctx.broadcastDelegation(group, message);
91
59
  else ctx.send(message);
92
60
  }
93
-
94
61
  function finishDelegation(group, caller, partner, token) {
95
62
  if (partner._pairDelegation !== token) return;
96
63
  delete partner._pairDelegation;
97
64
  delete partner._delegatedBy;
98
65
  broadcastDelegation(group, caller, partner, false);
99
66
  }
100
-
67
+ function completeInterruptedTask(partner, caller, token) {
68
+ if (!token) return null;
69
+ return taskControl.complete(partner, caller, token, "interrupted",
70
+ responseText(partner.history || [], token.startIndex), null);
71
+ }
101
72
  function resumeDriverWithMessage(caller, text, meta) {
102
73
  if (!caller || caller.destroying) return false;
103
74
  if (turnControl.blockedReason(caller)) return false;
@@ -134,7 +105,6 @@ function attachSessionPair(ctx) {
134
105
  sm.broadcastSessionList();
135
106
  return true;
136
107
  }
137
-
138
108
  function resumeDriverWithResult(caller, partner, token) {
139
109
  if (!token.detached || token.delivered || caller.destroying) return false;
140
110
  token.delivered = true;
@@ -154,14 +124,12 @@ function attachSessionPair(ctx) {
154
124
  partnerSessionId: partner.localId,
155
125
  });
156
126
  }
157
-
158
127
  function requestDetach(worker) {
159
128
  var token = worker && worker._pairDelegation;
160
129
  if (!token || token.detached || token.detachRequested) return false;
161
130
  token.detachRequested = true;
162
131
  return true;
163
132
  }
164
-
165
133
  function handleTurnDone(partner) {
166
134
  var token = partner && partner._pairDelegation;
167
135
  if (!token) return false;
@@ -176,10 +144,13 @@ function attachSessionPair(ctx) {
176
144
  token.response = responseText(partner.history || [], token.startIndex);
177
145
  token.failure = errorSince(partner.history || [], token.startIndex);
178
146
  token.interrupted = !token.failure && !!partner._lastTurnInterrupted;
147
+ if (token.interrupted) taskControl.ensureSystemInterruption(partner, token);
148
+ taskControl.complete(partner, caller, token, token.failure ? "failed" : (token.interrupted ? "interrupted" : "completed"), token.response, token.failure);
179
149
  finishDelegation(group, caller, partner, token);
180
- return resumeDriverWithResult(caller, partner, token);
150
+ var resumed = resumeDriverWithResult(caller, partner, token);
151
+ taskControl.drain(caller, partner);
152
+ return resumed;
181
153
  }
182
-
183
154
  function waitForPartner(group, caller, partner, token, timeoutSeconds) {
184
155
  return new Promise(function (resolve, reject) {
185
156
  var deadline = Date.now() + timeoutSeconds * 1000;
@@ -196,24 +167,29 @@ function attachSessionPair(ctx) {
196
167
  finishDelegation(group, caller, partner, token);
197
168
  var failure = errorSince(partner.history || [], token.startIndex);
198
169
  var interrupted = !failure && !!partner._lastTurnInterrupted;
170
+ var response = responseText(partner.history || [], token.startIndex);
171
+ if (interrupted) taskControl.ensureSystemInterruption(partner, token);
172
+ var outcome = taskControl.complete(partner, caller, token, failure ? "failed" : (interrupted ? "interrupted" : "completed"), response, failure);
199
173
  resolve({
200
- status: failure ? "error" : (interrupted ? "interrupted" : "complete"),
201
- response: responseText(partner.history || [], token.startIndex),
174
+ status: failure ? "error" : (outcome.status === "completed" ? "complete" : outcome.status),
175
+ taskId: token.taskId,
176
+ generation: token.generation,
177
+ response: response,
202
178
  error: failure || undefined,
179
+ outcome: outcome,
203
180
  });
181
+ taskControl.drain(caller, partner);
204
182
  return;
205
183
  }
206
- // Timeouts and permission requests both detach into the push-back path.
207
184
  if (Date.now() >= deadline || token.detachRequested) {
208
185
  clearInterval(timer);
209
186
  token.detached = true;
210
187
  monitorPartner(group, caller, partner, token);
211
- resolve({ status: "running", response: responseText(partner.history || [], token.startIndex), hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
188
+ resolve({ status: "running", taskId: token.taskId, generation: token.generation, response: responseText(partner.history || [], token.startIndex), hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
212
189
  }
213
190
  }, 500);
214
191
  });
215
192
  }
216
-
217
193
  function monitorPartner(group, caller, partner, token) {
218
194
  var timer = setInterval(function () {
219
195
  var currentGroup = store.groupForMember(caller.localId);
@@ -224,7 +200,6 @@ function attachSessionPair(ctx) {
224
200
  }
225
201
  }, 500);
226
202
  }
227
-
228
203
  async function sendToPartner(args, caller) {
229
204
  try {
230
205
  if (caller && caller._delegatedBy) throw new Error("delegated turns cannot delegate to another session");
@@ -238,7 +213,9 @@ function attachSessionPair(ctx) {
238
213
  timeout = Math.max(1, Math.min(900, timeout));
239
214
  var partner = resolved.partner;
240
215
  if (partner._pairDelegation) throw new Error("the partner is already handling a delegated task");
241
- var token = { from: caller.localId, groupId: resolved.group.id, startIndex: partner.history.length, message: message };
216
+ delete partner._pairInterruption; delete partner._lastInterruptedPairTask;
217
+ var task = taskControl.begin(partner, caller, message, args.taskId);
218
+ var token = Object.assign(task, { from: caller.localId, groupId: resolved.group.id, startIndex: partner.history.length });
242
219
  partner._pairDelegation = token;
243
220
  partner._delegatedBy = caller.localId;
244
221
  sm.sendAndRecord(partner, {
@@ -248,6 +225,8 @@ function attachSessionPair(ctx) {
248
225
  delegatedBy: caller.localId,
249
226
  delegatedByTitle: caller.title || "Driver",
250
227
  delegatedByVendor: caller.vendor || "claude",
228
+ delegatedTaskId: token.taskId,
229
+ delegatedGeneration: token.generation,
251
230
  });
252
231
  partner.lastActivity = Date.now();
253
232
  partner.sentToolResults = {};
@@ -259,9 +238,10 @@ function attachSessionPair(ctx) {
259
238
  ctx.onProcessingChanged();
260
239
  sm.sendToSession(partner, { type: "status", status: "processing" });
261
240
  }
262
- if (!sdk.pushMessage(partner, message)) {
241
+ var runtimeMessage = "[Split Worker taskId=" + token.taskId + " generation=" + (token.generation === null ? "unknown" : token.generation) + "]\n" + message + "\n\nBefore finishing, call report_partner_outcome with this exact taskId and only Worker-observed evidence.";
242
+ if (!sdk.pushMessage(partner, runtimeMessage)) {
263
243
  partner._queryStartTs = Date.now();
264
- Promise.resolve(sdk.startQuery(partner, message, undefined, ctx.getLinuxUserForSession(partner))).catch(function (err) {
244
+ Promise.resolve(sdk.startQuery(partner, runtimeMessage, undefined, ctx.getLinuxUserForSession(partner))).catch(function (err) {
265
245
  partner.isProcessing = false;
266
246
  sm.sendAndRecord(partner, { type: "error", text: err.message || String(err) });
267
247
  });
@@ -270,15 +250,21 @@ function attachSessionPair(ctx) {
270
250
  if (!wait) {
271
251
  token.detached = true;
272
252
  monitorPartner(resolved.group, caller, partner, token);
273
- return toolResult({ status: "running", partnerId: partner.localId, hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
253
+ return toolResult({ status: "running", partnerId: partner.localId, taskId: token.taskId, generation: token.generation, hint: "The completed result will be pushed back automatically. Use read_partner only for an interim status check." });
274
254
  }
275
255
  if (!partner.isProcessing && !partner._queryStarting) {
276
256
  finishDelegation(resolved.group, caller, partner, token);
277
257
  var immediateFailure = errorSince(partner.history || [], token.startIndex);
258
+ var immediateResponse = responseText(partner.history || [], token.startIndex);
259
+ var immediateOutcome = taskControl.complete(partner, caller, token, immediateFailure ? "failed" : "completed", immediateResponse, immediateFailure);
260
+ taskControl.drain(caller, partner);
278
261
  return toolResult({
279
- status: immediateFailure ? "error" : "complete",
280
- response: responseText(partner.history || [], token.startIndex),
262
+ status: immediateFailure ? "error" : (immediateOutcome.status === "completed" ? "complete" : immediateOutcome.status),
263
+ taskId: token.taskId,
264
+ generation: token.generation,
265
+ response: immediateResponse,
281
266
  error: immediateFailure || undefined,
267
+ outcome: immediateOutcome,
282
268
  });
283
269
  }
284
270
  var completed = await waitForPartner(resolved.group, caller, partner, token, timeout);
@@ -297,7 +283,6 @@ function attachSessionPair(ctx) {
297
283
  return toolError(e);
298
284
  }
299
285
  }
300
-
301
286
  function readPartner(args, caller) {
302
287
  try {
303
288
  var resolved = groupAndPartner(caller);
@@ -315,7 +300,6 @@ function attachSessionPair(ctx) {
315
300
  return toolError(e);
316
301
  }
317
302
  }
318
-
319
303
  function interruptPartner(args, caller) {
320
304
  try {
321
305
  var resolved = groupAndPartner(caller);
@@ -323,6 +307,7 @@ function attachSessionPair(ctx) {
323
307
  if (!partner.isProcessing && !partner._queryStarting) {
324
308
  return toolResult({ status: "idle", partnerId: partner.localId, title: partner.title || "New Session" });
325
309
  }
310
+ taskControl.markInterruption(partner, "driver", args.reason || "The Driver interrupted the active task.");
326
311
  partner.taskStopRequested = true;
327
312
  if (partner.abortController) partner.abortController.abort();
328
313
  return toolResult({ status: "interrupting", partnerId: partner.localId, title: partner.title || "New Session" });
@@ -330,17 +315,20 @@ function attachSessionPair(ctx) {
330
315
  return toolError(e);
331
316
  }
332
317
  }
333
-
334
318
  function closePartner(args, caller) {
335
319
  try {
336
320
  var resolved = groupAndPartner(caller);
337
321
  var partner = resolved.partner;
338
322
  var interrupted = !!(partner.isProcessing || partner._queryStarting);
339
323
  if (interrupted) {
324
+ taskControl.markInterruption(partner, "driver", "The Driver closed the Split Worker pair.");
340
325
  partner.taskStopRequested = true;
341
326
  if (partner.abortController) partner.abortController.abort();
342
327
  }
343
- if (partner._pairDelegation) finishDelegation(resolved.group, caller, partner, partner._pairDelegation);
328
+ if (partner._pairDelegation) {
329
+ completeInterruptedTask(partner, caller, partner._pairDelegation);
330
+ finishDelegation(resolved.group, caller, partner, partner._pairDelegation);
331
+ }
344
332
  workerPermission.cancelForSession(partner, "The Driver closed the Split Worker pair.");
345
333
  var ws = { _clayUser: caller.ownerId ? { id: caller.ownerId } : null };
346
334
  var result = store.dissolve(ws, { id: resolved.group.id });
@@ -350,18 +338,24 @@ function attachSessionPair(ctx) {
350
338
  return toolError(e);
351
339
  }
352
340
  }
353
-
354
341
  function handleHumanStop(session) {
355
342
  var roles = turnControl.markHumanStop(session);
356
343
  if (!roles) return false;
344
+ taskControl.markInterruption(roles.worker, "user", "The human stopped this Split Worker turn.");
357
345
  workerPermission.cancelForSession(roles.worker, "The human stopped this Split Worker turn.");
358
346
  return true;
359
347
  }
360
-
348
+ function beginHumanTurn(session) {
349
+ var resumed = turnControl.beginHumanTurn(session);
350
+ var roles = resumed && rolesFor(session);
351
+ if (roles) taskControl.drain(roles.driver, roles.worker);
352
+ return resumed;
353
+ }
361
354
  function getToolDefs(boundSession) {
362
355
  if (!boundSession) return pairMcp.getToolDefs({
363
356
  send: function () { return toolError(new Error("send_to_partner requires a session-bound tool server")); },
364
357
  read: function () { return toolError(new Error("read_partner requires a session-bound tool server")); },
358
+ message: function () { return toolError(new Error("message_partner requires a session-bound tool server")); },
365
359
  interrupt: function () { return toolError(new Error("interrupt_partner requires a session-bound tool server")); },
366
360
  close: function () { return toolError(new Error("close_partner requires a session-bound tool server")); },
367
361
  status: function () { return toolError(new Error("partner_status requires a session-bound tool server")); },
@@ -369,7 +363,7 @@ function attachSessionPair(ctx) {
369
363
  evaluate: function () { return toolError(new Error("record_partner_evaluation requires a session-bound tool server")); },
370
364
  });
371
365
  var group = store.groupForMember(boundSession.localId);
372
- if (group && group.pair && group.pair.driverId !== boundSession.localId) return [];
366
+ if (group && group.pair && group.pair.driverId !== boundSession.localId) return taskControl.workerToolDefs(boundSession);
373
367
  var adHocSplit = !!(group && !group.pair);
374
368
  if (!driverEligibility.isEligibleDriverSession(boundSession, sm)) return [];
375
369
  var persistentCatalog = boundSession.vendor === "codex" && (!group || !!group.pair);
@@ -382,34 +376,44 @@ function attachSessionPair(ctx) {
382
376
  send: function (args) {
383
377
  return turnControl.runOperation(boundSession, "send", args && args.operationId, function () {
384
378
  return sendToPartner(args || {}, boundSession);
385
- });
379
+ }, operationFingerprint(args, ["message", "wait", "timeoutSeconds", "taskId"])).catch(toolError);
386
380
  },
387
381
  read: function (args) { return readPartner(args, boundSession); },
382
+ message: function (args) {
383
+ return turnControl.runOperation(boundSession, "message", args && args.requestId, function () {
384
+ return pairMessage.send(args || {}, boundSession);
385
+ }, pairMessage.fingerprint(args || {})).catch(function (err) {
386
+ return toolResult({ status: "rejected", requestId: args && args.requestId || null, reason: err.message || String(err) });
387
+ });
388
+ },
388
389
  interrupt: function (args) { return interruptPartner(args, boundSession); },
389
390
  close: function (args) { return closePartner(args, boundSession); },
390
391
  status: lifecycleHandlers.status,
391
392
  replace: function (args) {
392
393
  return turnControl.runOperation(boundSession, "replace", args && args.operationId, function () {
393
394
  return workerProposal.proposeReplacement(args || {}, boundSession);
394
- });
395
+ }, operationFingerprint(args, ["interrupt", "message", "workerVendor", "workerModel", "workerEffort", "recommendationRationale", "evaluation", "supersedeProposalId"])).catch(toolError);
395
396
  },
396
397
  evaluate: lifecycleHandlers.evaluate,
397
398
  }, { lifecycle: persistentCatalog || !adHocSplit });
399
+ if (!adHocSplit) tools = tools.concat(taskControl.driverToolDefs({
400
+ queue: function (args) { return taskControl.queue(args || {}, boundSession); },
401
+ inspect: function (args) { return taskControl.inspect(args || {}, boundSession); },
402
+ cancel: function (args) { return taskControl.cancel(args || {}, boundSession); },
403
+ replace: function (args) { return taskControl.replaceTask(args || {}, boundSession); },
404
+ resume: function (args) { return taskControl.resumeTask(args || {}, boundSession); },
405
+ }));
398
406
  if (persistentCatalog) {
399
407
  return workerProposal.getToolDefs(boundSession, { persistent: true })
400
408
  .concat(tools)
401
409
  .concat(workerPermission.getToolDefs(boundSession, { dormantDriver: true }));
402
410
  }
403
- return tools
411
+ return tools.concat(adHocSplit ? [] : workerProposal.getToolDefs(boundSession, { persistent: true, controlsOnly: true }))
404
412
  .concat(workerPermission.getToolDefs(boundSession, { dormantDriver: false }));
405
413
  }
406
-
407
- var factory = attachPairFactory(ctx);
408
- var createPairRecord = factory.createPairRecord;
409
- var createWorkerForDriver = factory.createWorkerForDriver;
410
- var createPair = factory.createPair;
411
- var preflightWorkerForDriver = factory.preflightWorkerForDriver;
412
-
414
+ var factory = attachPairFactory(ctx), createPairRecord = factory.createPairRecord,
415
+ createWorkerForDriver = factory.createWorkerForDriver, createPair = factory.createPair,
416
+ preflightWorkerForDriver = factory.preflightWorkerForDriver;
413
417
  lifecycle = attachPairLifecycle({
414
418
  sm: sm,
415
419
  splitStore: store,
@@ -417,12 +421,13 @@ function attachSessionPair(ctx) {
417
421
  preflightWorkerForDriver: function (driver, args) { return preflightWorkerForDriver(driver, args); },
418
422
  sendToPartner: function (args, caller) { return sendToPartner(args, caller); },
419
423
  finishDelegation: finishDelegation,
420
- cancelWorkerPermissions: function (worker, reason) {
421
- return workerPermission.cancelForSession(worker, reason);
422
- },
424
+ completeInterruptedTask: completeInterruptedTask,
425
+ cancelWorkerPermissions: function (worker, reason) { return workerPermission.cancelForSession(worker, reason); },
426
+ markInterruption: function (worker, by, reason) { return taskControl.markInterruption(worker, by, reason); },
427
+ taskStatus: function (worker) { return taskControl.status(worker); },
428
+ proposalStatus: function (driver) { return workerProposal ? workerProposal.proposalStatus(driver) : null; },
423
429
  turnControl: turnControl,
424
430
  });
425
-
426
431
  workerPermission = attachWorkerPermission({
427
432
  sm: sm,
428
433
  splitStore: store,
@@ -430,7 +435,6 @@ function attachSessionPair(ctx) {
430
435
  resumeDriverWithMessage: resumeDriverWithMessage,
431
436
  requestDetach: requestDetach,
432
437
  });
433
-
434
438
  workerProposal = attachWorkerProposal({
435
439
  sm: sm,
436
440
  isMate: ctx.isMate,
@@ -446,8 +450,8 @@ function attachSessionPair(ctx) {
446
450
  recordGenerationStart: function (driver, worker) { return lifecycle.recordGenerationStart(driver, worker); },
447
451
  sendToPartner: sendToPartner,
448
452
  replacePartner: function (args, session) { return lifecycle.replacePartner(args, session); },
453
+ preflightWorkerForDriver: preflightWorkerForDriver,
449
454
  });
450
-
451
455
  function handleMessage(ws, msg) {
452
456
  if (workerProposal.handleMessage(ws, msg)) return true;
453
457
  if (msg.type === "pair_session_options") {
@@ -467,8 +471,12 @@ function attachSessionPair(ctx) {
467
471
  }
468
472
  return false;
469
473
  }
470
-
471
474
  function getSystemPrompt(session) {
475
+ var roles = rolesFor(session);
476
+ if (roles && roles.worker === session) {
477
+ var taskId = session._pairDelegation && session._pairDelegation.taskId || "unknown";
478
+ return pairPrompts.worker(taskId);
479
+ }
472
480
  var group = store.groupForMember(session.localId);
473
481
  var pairPrompt = "";
474
482
  if (group && group.pair && group.pair.driverId === session.localId) {
@@ -482,17 +490,8 @@ function attachSessionPair(ctx) {
482
490
  }
483
491
  return pairPrompt;
484
492
  }
485
-
486
- return {
487
- beginHumanTurn: turnControl.beginHumanTurn,
488
- getToolDefs: getToolDefs,
489
- handleHumanStop: handleHumanStop,
490
- handleMessage: handleMessage,
491
- handleTurnDone: handleTurnDone,
492
- getSystemPrompt: getSystemPrompt,
493
- respondToWorkerProposal: workerProposal.respondToProposal,
494
- workerPermission: workerPermission,
495
- };
493
+ return { beginHumanTurn: beginHumanTurn, getToolDefs: getToolDefs, handleHumanStop: handleHumanStop,
494
+ handleMessage: handleMessage, handleTurnDone: handleTurnDone, getSystemPrompt: getSystemPrompt,
495
+ respondToWorkerProposal: workerProposal.respondToProposal, workerPermission: workerPermission };
496
496
  }
497
-
498
497
  module.exports = { attachSessionPair: attachSessionPair, responseText: responseText, recentTurns: recentTurns };
@@ -87,6 +87,13 @@ function attachSessions(ctx) {
87
87
  var getLinuxUserForSession = ctx.getLinuxUserForSession;
88
88
  var ensureProjectAccessForSession = ctx.ensureProjectAccessForSession;
89
89
  var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
90
+ function stopSession(session) {
91
+ if (!session || (!session.isProcessing && !session._queryStarting)) return false;
92
+ if (typeof ctx.onHumanPairStop === "function") ctx.onHumanPairStop(session);
93
+ session.taskStopRequested = true;
94
+ if (session.abortController) session.abortController.abort();
95
+ return true;
96
+ }
90
97
  var hydrateImageRefs = ctx.hydrateImageRefs;
91
98
  var onProcessingChanged = ctx.onProcessingChanged;
92
99
  var broadcastPresence = ctx.broadcastPresence;
@@ -165,6 +172,14 @@ function attachSessions(ctx) {
165
172
  var setLatestVersion = ctx.setLatestVersion;
166
173
  var loadContextSources = ctx.loadContextSources;
167
174
 
175
+ function canAccessProjectSlug(ws, targetSlug) {
176
+ if (!targetSlug) return false;
177
+ if (!usersModule || typeof usersModule.isMultiUser !== "function" || !usersModule.isMultiUser()) return true;
178
+ var userId = ws && ws._clayUser ? ws._clayUser.id : null;
179
+ if (!userId || typeof opts.canAccessProjectSlug !== "function") return false;
180
+ return opts.canAccessProjectSlug(userId, targetSlug) === true;
181
+ }
182
+
168
183
  // Resolve the active user's Claude open-mode preference ('gui' or 'tui').
169
184
  // Multi-user mode reads per-user storage; single-user mode falls back to
170
185
  // the daemon-level default ('gui').
@@ -1004,11 +1019,7 @@ function attachSessions(ctx) {
1004
1019
 
1005
1020
  if (msg.type === "stop") {
1006
1021
  var session = getSessionForWs(ws);
1007
- if (session && (session.isProcessing || session._queryStarting)) {
1008
- if (typeof ctx.onHumanPairStop === "function") ctx.onHumanPairStop(session);
1009
- session.taskStopRequested = true;
1010
- if (session.abortController) session.abortController.abort();
1011
- }
1022
+ stopSession(session);
1012
1023
  return true;
1013
1024
  }
1014
1025
 
@@ -1770,6 +1781,10 @@ function attachSessions(ctx) {
1770
1781
 
1771
1782
  // --- Create worktree from web UI ---
1772
1783
  if (msg.type === "create_worktree") {
1784
+ if (slug.indexOf("--") !== -1) {
1785
+ sendTo(ws, { type: "create_worktree_result", ok: false, error: "Cannot create a worktree from another worktree" });
1786
+ return true;
1787
+ }
1773
1788
  var wtBranch = (msg.branch || "").trim();
1774
1789
  var wtDirName = (msg.dirName || "").trim() || wtBranch.replace(/\//g, "-");
1775
1790
  var wtBase = (msg.baseBranch || "").trim() || null;
@@ -1793,7 +1808,7 @@ function attachSessions(ctx) {
1793
1808
  // --- Pre-check: does the project have tasks/schedules? ---
1794
1809
  if (msg.type === "remove_project_check") {
1795
1810
  var checkSlug = msg.slug;
1796
- if (!checkSlug) {
1811
+ if (!checkSlug || !canAccessProjectSlug(ws, checkSlug)) {
1797
1812
  sendTo(ws, { type: "remove_project_check_result", slug: checkSlug, name: msg.name || checkSlug, count: 0 });
1798
1813
  return true;
1799
1814
  }
@@ -1816,8 +1831,16 @@ function attachSessions(ctx) {
1816
1831
  sendTo(ws, { type: "remove_project_result", ok: false, error: "Missing slug" });
1817
1832
  return true;
1818
1833
  }
1834
+ if (!canAccessProjectSlug(ws, removeSlug)) {
1835
+ sendTo(ws, { type: "remove_project_result", ok: false, error: "You do not have access to this project" });
1836
+ return true;
1837
+ }
1819
1838
  // If client chose to move tasks to another project before removing
1820
1839
  if (msg.moveTasksTo) {
1840
+ if (!canAccessProjectSlug(ws, msg.moveTasksTo)) {
1841
+ sendTo(ws, { type: "remove_project_result", ok: false, error: "You do not have access to the destination project" });
1842
+ return true;
1843
+ }
1821
1844
  moveAllSchedulesToProject(removeSlug, msg.moveTasksTo);
1822
1845
  }
1823
1846
  if (typeof opts.onRemoveProject === "function") {
@@ -1833,6 +1856,10 @@ function attachSessions(ctx) {
1833
1856
 
1834
1857
  // --- Move a single schedule to another project ---
1835
1858
  if (msg.type === "schedule_move") {
1859
+ if (!canAccessProjectSlug(ws, msg.fromSlug) || !canAccessProjectSlug(ws, msg.toSlug)) {
1860
+ sendTo(ws, { type: "schedule_move_result", ok: false, error: "You do not have access to one of these projects" });
1861
+ return true;
1862
+ }
1836
1863
  var moveResult = moveScheduleToProject(msg.recordId, msg.fromSlug, msg.toSlug);
1837
1864
  if (moveResult.ok) {
1838
1865
  // Re-broadcast updated records to this project's clients
@@ -2071,6 +2098,13 @@ function attachSessions(ctx) {
2071
2098
  handleSessionsMessage: handleSessionsMessage,
2072
2099
  resolveSessionForView: resolveSessionForView,
2073
2100
  respondToAskUser: respondToAskUser,
2101
+ respondToHomePermission: function (ws, msg, session) {
2102
+ if (!session || !msg || !session.pendingPermissions || !session.pendingPermissions[msg.requestId]) return false;
2103
+ var indexedSessionId = sm.permissionRequestIndex[msg.requestId];
2104
+ if (indexedSessionId !== session.localId || sm.sessions.get(indexedSessionId) !== session) return false;
2105
+ return handleSessionsMessage(ws, { type: "permission_response", requestId: msg.requestId, decision: msg.decision });
2106
+ },
2107
+ stopHomeSession: stopSession,
2074
2108
  };
2075
2109
  }
2076
2110