clay-server 3.2.2-beta.3 → 3.3.0-beta.2

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.
@@ -9,31 +9,6 @@ var smtp = require("./smtp");
9
9
  var { CONFIG_DIR } = require("./config");
10
10
 
11
11
  var AUDIT_LOG_PATH = path.join(CONFIG_DIR, "email-audit.jsonl");
12
- var EMAIL_DEFAULTS_DIR = path.join(CONFIG_DIR, "email-defaults");
13
-
14
- // --- Project-level email defaults ---
15
- // Stores which email accounts should be auto-enabled for every new session.
16
-
17
- function loadEmailDefaults(slug) {
18
- try {
19
- var filePath = path.join(EMAIL_DEFAULTS_DIR, slug + ".json");
20
- var data = JSON.parse(fs.readFileSync(filePath, "utf8"));
21
- return data.accounts || [];
22
- } catch (e) {
23
- return [];
24
- }
25
- }
26
-
27
- function saveEmailDefaults(slug, accountIds) {
28
- try {
29
- fs.mkdirSync(EMAIL_DEFAULTS_DIR, { recursive: true });
30
- var filePath = path.join(EMAIL_DEFAULTS_DIR, slug + ".json");
31
- fs.writeFileSync(filePath, JSON.stringify({ accounts: accountIds }), "utf8");
32
- } catch (e) {
33
- console.error("[email] Failed to save defaults:", e.message);
34
- }
35
- }
36
-
37
12
  // --- Audit log (Server SMTP only) ---
38
13
 
39
14
  function appendAuditLog(entry) {
@@ -185,7 +160,6 @@ function formatTimeAgo(date) {
185
160
  function attachEmail(ctx) {
186
161
  var slug = ctx.slug;
187
162
  var send = ctx.send;
188
- var sendTo = ctx.sendTo;
189
163
  var clients = ctx.clients;
190
164
  var loadContextSources = ctx.loadContextSources;
191
165
  var getUserIdForWs = ctx.getUserIdForWs;
@@ -327,17 +301,6 @@ function attachEmail(ctx) {
327
301
  }
328
302
  }
329
303
  }
330
- // Fallback: if no session-level sources found, check project email defaults
331
- if (result.length === 0) {
332
- var defaults = loadEmailDefaults(slug);
333
- for (var di = 0; di < defaults.length; di++) {
334
- var dec = emailAccounts.getAccountDecrypted(userId, defaults[di]);
335
- if (dec && !seen[dec.id]) {
336
- seen[dec.id] = true;
337
- result.push(dec);
338
- }
339
- }
340
- }
341
304
  return result;
342
305
  }
343
306
 
@@ -376,28 +339,6 @@ function attachEmail(ctx) {
376
339
  };
377
340
  }
378
341
 
379
- function handleEmailMessage(ws, msg) {
380
- if (msg.type === "email_defaults_get") {
381
- var defaults = loadEmailDefaults(slug);
382
- sendTo(ws, { type: "email_defaults", accounts: defaults });
383
- return true;
384
- }
385
- if (msg.type === "email_defaults_save") {
386
- var accountIds = msg.accounts || [];
387
- saveEmailDefaults(slug, accountIds);
388
- // Broadcast to all clients on this project
389
- var _defMsg = JSON.stringify({ type: "email_defaults", accounts: accountIds });
390
- for (var c of clients) { if (c.readyState === 1) c.send(_defMsg); }
391
- return true;
392
- }
393
- return false;
394
- }
395
-
396
- // Get default email account IDs for new sessions
397
- function getEmailDefaults() {
398
- return loadEmailDefaults(slug);
399
- }
400
-
401
342
  function destroy() {
402
343
  if (pollTimer) {
403
344
  clearInterval(pollTimer);
@@ -421,10 +362,8 @@ function attachEmail(ctx) {
421
362
  }
422
363
 
423
364
  return {
424
- handleEmailMessage: handleEmailMessage,
425
365
  getEmailContext: getEmailContext,
426
366
  getCheckedEmailAccounts: getCheckedEmailAccounts,
427
- getEmailDefaults: getEmailDefaults,
428
367
  createMcpDeps: createMcpDeps,
429
368
  hasEmailCapability: hasEmailCapability,
430
369
  destroy: destroy,
@@ -5,6 +5,7 @@ var crypto = require("crypto");
5
5
  var { execFileSync, spawn } = require("child_process");
6
6
  var { fsAsUser } = require("./os-users");
7
7
  var usersModule = require("./users");
8
+ var gitCli = require("./git-cli");
8
9
 
9
10
  var IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"]);
10
11
  var MIME_TYPES = {
@@ -47,6 +48,7 @@ function attachHTTP(ctx) {
47
48
  var slug = ctx.slug;
48
49
  var project = ctx.project;
49
50
  var sm = ctx.sm;
51
+ var gitAttribution = ctx.gitAttribution;
50
52
  var send = ctx.send;
51
53
  var imagesDir = ctx.imagesDir;
52
54
  var osUsers = ctx.osUsers;
@@ -643,7 +645,108 @@ function attachHTTP(ctx) {
643
645
  return true;
644
646
  }
645
647
 
646
- // Git dirty check
648
+ // Git panel status and actions
649
+ var isGitPanelRequest = urlPath === "/api/git/status" ||
650
+ urlPath === "/api/git/action" || urlPath.indexOf("/api/git/file-diff?") === 0 ||
651
+ urlPath.indexOf("/api/git/session-diff?") === 0;
652
+ if (isGitPanelRequest && usersModule.isMultiUser()) {
653
+ var gitRequestUser = req._clayUser;
654
+ var gitPermissions = gitRequestUser ? usersModule.getEffectivePermissions(gitRequestUser, osUsers) : null;
655
+ if (!gitPermissions || !gitPermissions.fileBrowser) {
656
+ res.writeHead(403, { "Content-Type": "application/json" });
657
+ res.end('{"error":"Git access is not permitted"}');
658
+ return true;
659
+ }
660
+ }
661
+
662
+ if (req.method === "GET" && urlPath === "/api/git/status") {
663
+ var gitUserInfo = getOsUserInfoForReq(req);
664
+ try {
665
+ var gitStatus = gitCli.getStatus(cwd, gitUserInfo);
666
+ if (gitAttribution) {
667
+ var visibleGitSessions = sm.sessions;
668
+ if (usersModule.isMultiUser() && req._clayUser) {
669
+ visibleGitSessions = new Map();
670
+ sm.sessions.forEach(function (session, id) {
671
+ if (usersModule.canAccessSession(req._clayUser.id, session, { visibility: "public" })) {
672
+ visibleGitSessions.set(id, session);
673
+ }
674
+ });
675
+ }
676
+ gitAttribution.decorateStatus(gitStatus, visibleGitSessions);
677
+ }
678
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
679
+ res.end(JSON.stringify(gitStatus));
680
+ } catch (e) {
681
+ res.writeHead(500, { "Content-Type": "application/json" });
682
+ res.end(JSON.stringify({ error: e.message || "Unable to read Git status" }));
683
+ }
684
+ return true;
685
+ }
686
+
687
+ if (req.method === "GET" && urlPath.indexOf("/api/git/file-diff?") === 0) {
688
+ var gitDiffParams = new URLSearchParams(urlPath.slice(urlPath.indexOf("?")));
689
+ var gitDiffPath = gitDiffParams.get("path");
690
+ if (!gitDiffPath) {
691
+ res.writeHead(400, { "Content-Type": "application/json" });
692
+ res.end('{"error":"Missing file path"}');
693
+ return true;
694
+ }
695
+ try {
696
+ var gitDiffResult = gitCli.getFileDiff(cwd, gitDiffPath, getOsUserInfoForReq(req));
697
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
698
+ res.end(JSON.stringify(gitDiffResult));
699
+ } catch (e) {
700
+ res.writeHead(400, { "Content-Type": "application/json" });
701
+ res.end(JSON.stringify({ error: e.message || "Unable to read file diff" }));
702
+ }
703
+ return true;
704
+ }
705
+
706
+ if (req.method === "GET" && urlPath.indexOf("/api/git/session-diff?") === 0) {
707
+ var sessionDiffParams = new URLSearchParams(urlPath.slice(urlPath.indexOf("?")));
708
+ var sessionDiffKey = sessionDiffParams.get("session");
709
+ var sessionDiffPath = sessionDiffParams.get("path");
710
+ if (!gitAttribution || !sessionDiffKey || !sessionDiffPath) {
711
+ res.writeHead(400, { "Content-Type": "application/json" });
712
+ res.end('{"error":"Missing session comparison"}');
713
+ return true;
714
+ }
715
+ try {
716
+ var requestedSessionVisible = false;
717
+ sm.sessions.forEach(function (session) {
718
+ var key = session.cliSessionId || ("local:" + session.localId);
719
+ if (key !== sessionDiffKey) return;
720
+ requestedSessionVisible = !usersModule.isMultiUser() ||
721
+ (req._clayUser && usersModule.canAccessSession(req._clayUser.id, session, { visibility: "public" }));
722
+ });
723
+ if (!requestedSessionVisible) throw new Error("Session comparison is unavailable");
724
+ var sessionDiff = gitAttribution.getSessionBaselineDiff(sessionDiffKey, sessionDiffPath, getOsUserInfoForReq(req));
725
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
726
+ res.end(JSON.stringify(sessionDiff));
727
+ } catch (e) {
728
+ res.writeHead(400, { "Content-Type": "application/json" });
729
+ res.end(JSON.stringify({ error: e.message || "Unable to compare session changes" }));
730
+ }
731
+ return true;
732
+ }
733
+
734
+ if (req.method === "POST" && urlPath === "/api/git/action") {
735
+ var gitActionUserInfo = getOsUserInfoForReq(req);
736
+ parseJsonBody(req).then(function (body) {
737
+ return gitCli.runAction(cwd, body, gitActionUserInfo);
738
+ }).then(function (output) {
739
+ var updatedStatus = gitCli.getStatus(cwd, gitActionUserInfo);
740
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
741
+ res.end(JSON.stringify({ ok: true, output: output, status: updatedStatus }));
742
+ }).catch(function (err) {
743
+ res.writeHead(400, { "Content-Type": "application/json" });
744
+ res.end(JSON.stringify({ error: err.userMessage || err.message || "Git action failed" }));
745
+ });
746
+ return true;
747
+ }
748
+
749
+ // Legacy dirty check used by the Ralph Loop flow
647
750
  if (req.method === "GET" && urlPath === "/api/git-dirty") {
648
751
  try {
649
752
  var out = execFileSync("git", ["status", "--porcelain"], { cwd: cwd, encoding: "utf8", timeout: 5000 });
@@ -1,4 +1,5 @@
1
1
  var pairMcp = require("./session-pair-mcp-server");
2
+ var { attachWorkerProposal } = require("./project-worker-proposal");
2
3
  var yoke = require("./yoke");
3
4
 
4
5
  var MAX_RESPONSE_CHARS = 30000;
@@ -57,6 +58,7 @@ function recentTurns(session, count) {
57
58
  function attachSessionPair(ctx) {
58
59
  var sm = ctx.sm;
59
60
  var store = ctx.splitStore;
61
+ var workerProposal;
60
62
 
61
63
  function groupAndPartner(caller) {
62
64
  if (!caller) throw new Error("partner tools require a session-bound tool server");
@@ -91,6 +93,63 @@ function attachSessionPair(ctx) {
91
93
  broadcastDelegation(group, caller, partner, false);
92
94
  }
93
95
 
96
+ function resumeDriverWithResult(caller, partner, token) {
97
+ if (!token.detached || token.delivered || caller.destroying) return false;
98
+ token.delivered = true;
99
+ var failure = token.failure || null;
100
+ var response = token.response || "";
101
+ var text = "A Worker task delegated through send_to_partner has finished.\n\n" +
102
+ "Original task:\n" + token.message + "\n\n" +
103
+ (failure ? "Worker error:\n" + failure : "Worker result:\n" + (response || "(No text response was recorded.)")) +
104
+ "\n\nReview the result, verify it as needed, and continue the task.";
105
+ sm.sendAndRecord(caller, {
106
+ type: "user_message",
107
+ text: text,
108
+ _internal: true,
109
+ partnerResult: true,
110
+ partnerSessionId: partner.localId,
111
+ });
112
+ caller.lastActivity = Date.now();
113
+ var sdk = ctx.getSdk();
114
+ if (!sdk) {
115
+ sm.sendAndRecord(caller, { type: "error", text: "Could not resume the Driver: SDK bridge is not ready" });
116
+ return false;
117
+ }
118
+ if (!caller.isProcessing) {
119
+ caller.isProcessing = true;
120
+ caller.sentToolResults = {};
121
+ ctx.onProcessingChanged();
122
+ sm.sendToSession(caller, { type: "status", status: "processing" });
123
+ }
124
+ if (!sdk.pushMessage(caller, text)) {
125
+ caller._queryStartTs = Date.now();
126
+ Promise.resolve(sdk.startQuery(caller, text, undefined, ctx.getLinuxUserForSession(caller))).catch(function (err) {
127
+ caller.isProcessing = false;
128
+ sm.sendAndRecord(caller, { type: "error", text: err.message || String(err) });
129
+ ctx.onProcessingChanged();
130
+ });
131
+ }
132
+ sm.broadcastSessionList();
133
+ return true;
134
+ }
135
+
136
+ function handleTurnDone(partner) {
137
+ var token = partner && partner._pairDelegation;
138
+ if (!token) return false;
139
+ var group = store.groupForMember(partner.localId);
140
+ if (!group || group.id !== token.groupId || group.members.indexOf(token.from) === -1) return false;
141
+ var caller = sm.sessions.get(token.from);
142
+ if (!caller || caller.ownerId !== partner.ownerId) return false;
143
+ if (group.pair && (group.pair.driverId !== caller.localId || group.pair.workerId !== partner.localId)) {
144
+ finishDelegation(group, caller, partner, token);
145
+ return false;
146
+ }
147
+ token.response = responseText(partner.history || [], token.startIndex);
148
+ token.failure = errorSince(partner.history || [], token.startIndex);
149
+ finishDelegation(group, caller, partner, token);
150
+ return resumeDriverWithResult(caller, partner, token);
151
+ }
152
+
94
153
  function waitForPartner(group, caller, partner, token, timeoutSeconds) {
95
154
  return new Promise(function (resolve, reject) {
96
155
  var deadline = Date.now() + timeoutSeconds * 1000;
@@ -115,8 +174,9 @@ function attachSessionPair(ctx) {
115
174
  }
116
175
  if (Date.now() >= deadline) {
117
176
  clearInterval(timer);
177
+ token.detached = true;
118
178
  monitorPartner(group, caller, partner, token);
119
- resolve({ status: "running", response: responseText(partner.history || [], token.startIndex), hint: "Use read_partner to check again." });
179
+ 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." });
120
180
  }
121
181
  }, 500);
122
182
  });
@@ -127,7 +187,8 @@ function attachSessionPair(ctx) {
127
187
  var currentGroup = store.groupForMember(caller.localId);
128
188
  if (!currentGroup || currentGroup.id !== group.id || (!partner.isProcessing && !partner._queryStarting)) {
129
189
  clearInterval(timer);
130
- finishDelegation(group, caller, partner, token);
190
+ if (currentGroup && currentGroup.id === group.id) handleTurnDone(partner);
191
+ else finishDelegation(group, caller, partner, token);
131
192
  }
132
193
  }, 500);
133
194
  }
@@ -143,7 +204,7 @@ function attachSessionPair(ctx) {
143
204
  timeout = Math.max(1, Math.min(900, timeout));
144
205
  var partner = resolved.partner;
145
206
  if (partner._pairDelegation) throw new Error("the partner is already handling a delegated task");
146
- var token = { from: caller.localId, groupId: resolved.group.id, startIndex: partner.history.length };
207
+ var token = { from: caller.localId, groupId: resolved.group.id, startIndex: partner.history.length, message: message };
147
208
  partner._pairDelegation = token;
148
209
  partner._delegatedBy = caller.localId;
149
210
  sm.sendAndRecord(partner, {
@@ -173,8 +234,9 @@ function attachSessionPair(ctx) {
173
234
  }
174
235
  sm.broadcastSessionList();
175
236
  if (!wait) {
237
+ token.detached = true;
176
238
  monitorPartner(resolved.group, caller, partner, token);
177
- return toolResult({ status: "running", partnerId: partner.localId, hint: "Use read_partner to check progress." });
239
+ 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." });
178
240
  }
179
241
  return toolResult(await waitForPartner(resolved.group, caller, partner, token, timeout));
180
242
  } catch (e) {
@@ -223,10 +285,11 @@ function attachSessionPair(ctx) {
223
285
  // pair worker at query start never sees the tools.
224
286
  var group = store.groupForMember(boundSession.localId);
225
287
  if (group && group.pair && group.pair.driverId !== boundSession.localId) return [];
226
- return pairMcp.getToolDefs({
288
+ var tools = pairMcp.getToolDefs({
227
289
  send: function (args) { return sendToPartner(args, boundSession); },
228
290
  read: function (args) { return readPartner(args, boundSession); },
229
291
  });
292
+ return tools.concat(workerProposal.getToolDefs(boundSession));
230
293
  }
231
294
 
232
295
  function validateVendor(vendor) {
@@ -235,49 +298,69 @@ function attachSessionPair(ctx) {
235
298
  return vendor;
236
299
  }
237
300
 
301
+ function createPairRecord(ws, msg) {
302
+ if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
303
+ var driverSpec = msg.driver || {};
304
+ var workerSpec = msg.worker || {};
305
+ var workerVendor = validateVendor(workerSpec.vendor || sm.lastVendor || "codex");
306
+ var ownerId = ws._clayUser && ctx.usersModule.isMultiUser() ? ws._clayUser.id : null;
307
+ var driver;
308
+ var groupName;
309
+ if (Number.isInteger(driverSpec.sessionId)) {
310
+ // "Add Worker" on an existing session: the current session becomes
311
+ // the Driver with its full conversation context intact.
312
+ driver = sm.sessions.get(driverSpec.sessionId);
313
+ if (!driver) throw new Error("driver session not found");
314
+ if ((driver.ownerId || null) !== ownerId) throw new Error("driver session access denied");
315
+ if (store.groupForMember(driver.localId)) throw new Error("this session is already in a split group");
316
+ groupName = undefined; // auto name from member titles
317
+ } else {
318
+ var driverVendor = validateVendor(driverSpec.vendor || "claude");
319
+ var driverEffort = yoke.clampEffort(driverVendor, driverSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[driverVendor]) || sm.currentEffort || "medium") || null;
320
+ driver = sm.createSessionRaw({ ownerId: ownerId, vendor: driverVendor, model: driverSpec.model || null, effort: driverEffort });
321
+ driver.title = "Driver · " + ((yoke.getVendorInfo(driverVendor) || {}).displayName || driverVendor);
322
+ if (driverSpec.effort) driver.loopSettings = { effort: driverSpec.effort };
323
+ groupName = "Agent pair";
324
+ }
325
+ var workerEffort = yoke.clampEffort(workerVendor, workerSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[workerVendor]) || sm.currentEffort || "medium") || null;
326
+ var worker = sm.createSessionRaw({ ownerId: ownerId, vendor: workerVendor, model: workerSpec.model || null, effort: workerEffort });
327
+ worker.title = "Worker · " + ((yoke.getVendorInfo(workerVendor) || {}).displayName || workerVendor);
328
+ if (workerSpec.effort) worker.loopSettings = { effort: workerSpec.effort };
329
+ var result = store.create(ws, {
330
+ members: [driver.localId, worker.localId],
331
+ pair: { driverId: driver.localId, workerId: worker.localId },
332
+ name: groupName,
333
+ });
334
+ if (!result.ok) throw new Error(result.error);
335
+ sm.broadcastSessionList();
336
+ return { driver: driver, worker: worker, group: result.group };
337
+ }
338
+
238
339
  function createPair(ws, msg) {
239
340
  try {
240
- if (ctx.isMate) throw new Error("Pair sessions are only available in projects");
241
- var driverSpec = msg.driver || {};
242
- var workerSpec = msg.worker || {};
243
- var workerVendor = validateVendor(workerSpec.vendor || sm.lastVendor || "codex");
244
- var ownerId = ws._clayUser && ctx.usersModule.isMultiUser() ? ws._clayUser.id : null;
245
- var driver;
246
- var groupName;
247
- if (Number.isInteger(driverSpec.sessionId)) {
248
- // "Add Worker" on an existing session: the current session becomes
249
- // the Driver with its full conversation context intact.
250
- driver = sm.sessions.get(driverSpec.sessionId);
251
- if (!driver) throw new Error("driver session not found");
252
- if ((driver.ownerId || null) !== ownerId) throw new Error("driver session access denied");
253
- if (store.groupForMember(driver.localId)) throw new Error("this session is already in a split group");
254
- groupName = undefined; // auto name from member titles
255
- } else {
256
- var driverVendor = validateVendor(driverSpec.vendor || "claude");
257
- var driverEffort = yoke.clampEffort(driverVendor, driverSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[driverVendor]) || sm.currentEffort || "medium") || null;
258
- driver = sm.createSessionRaw({ ownerId: ownerId, vendor: driverVendor, model: driverSpec.model || null, effort: driverEffort });
259
- driver.title = "Driver · " + ((yoke.getVendorInfo(driverVendor) || {}).displayName || driverVendor);
260
- if (driverSpec.effort) driver.loopSettings = { effort: driverSpec.effort };
261
- groupName = "Agent pair";
262
- }
263
- var workerEffort = yoke.clampEffort(workerVendor, workerSpec.effort || (sm.currentEffortByVendor && sm.currentEffortByVendor[workerVendor]) || sm.currentEffort || "medium") || null;
264
- var worker = sm.createSessionRaw({ ownerId: ownerId, vendor: workerVendor, model: workerSpec.model || null, effort: workerEffort });
265
- worker.title = "Worker · " + ((yoke.getVendorInfo(workerVendor) || {}).displayName || workerVendor);
266
- if (workerSpec.effort) worker.loopSettings = { effort: workerSpec.effort };
267
- var result = store.create(ws, {
268
- members: [driver.localId, worker.localId],
269
- pair: { driverId: driver.localId, workerId: worker.localId },
270
- name: groupName,
271
- });
272
- if (!result.ok) throw new Error(result.error);
273
- sm.broadcastSessionList();
274
- ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: result.group });
341
+ var created = createPairRecord(ws, msg);
342
+ ctx.sendTo(ws, { type: "pair_session_created", ok: true, group: created.group });
275
343
  } catch (e) {
276
344
  ctx.sendTo(ws, { type: "pair_session_created", ok: false, error: e.message || String(e) });
277
345
  }
278
346
  }
279
347
 
348
+ workerProposal = attachWorkerProposal({
349
+ sm: sm,
350
+ isMate: ctx.isMate,
351
+ splitStore: store,
352
+ getSdk: ctx.getSdk,
353
+ sendTo: ctx.sendTo,
354
+ usersModule: ctx.usersModule,
355
+ getLinuxUserForSession: ctx.getLinuxUserForSession,
356
+ onProcessingChanged: ctx.onProcessingChanged,
357
+ adapters: ctx.adapters,
358
+ createPairRecord: createPairRecord,
359
+ sendToPartner: sendToPartner,
360
+ });
361
+
280
362
  function handleMessage(ws, msg) {
363
+ if (workerProposal.handleMessage(ws, msg)) return true;
281
364
  if (msg.type === "pair_session_options") {
282
365
  if (ctx.isMate) return false;
283
366
  ctx.sendTo(ws, {
@@ -298,11 +381,14 @@ function attachSessionPair(ctx) {
298
381
 
299
382
  function getSystemPrompt(session) {
300
383
  var group = store.groupForMember(session.localId);
301
- if (!group || !group.pair || group.pair.driverId !== session.localId) return "";
302
- return "You are the Driver in a two-agent pair. The tools send_to_partner and read_partner are provided directly to you. Use send_to_partner to delegate concrete bounded work to the Worker, use read_partner to inspect its progress or recent results, then integrate and verify the final outcome. Do not search the project for implementations of these tools, and do not delegate work that must be performed sequentially in this same session.";
384
+ var pairPrompt = "";
385
+ if (group && group.pair && group.pair.driverId === session.localId) {
386
+ pairPrompt = "You are the Driver in a two-agent pair. The tools send_to_partner and read_partner are provided directly to you. Use send_to_partner to delegate concrete bounded work to the Worker. If a non-waiting delegation finishes later, Clay pushes the result back and resumes you automatically; use read_partner only when you need an interim status check. Integrate and verify the final outcome. Do not search the project for implementations of these tools, and do not delegate work that must be performed sequentially in this same session.";
387
+ }
388
+ return [pairPrompt, workerProposal.getSystemPrompt(session)].filter(Boolean).join("\n\n");
303
389
  }
304
390
 
305
- return { getToolDefs: getToolDefs, handleMessage: handleMessage, getSystemPrompt: getSystemPrompt };
391
+ return { getToolDefs: getToolDefs, handleMessage: handleMessage, handleTurnDone: handleTurnDone, getSystemPrompt: getSystemPrompt };
306
392
  }
307
393
 
308
394
  module.exports = {
@@ -104,7 +104,6 @@ function attachSessions(ctx) {
104
104
  var getLatestVersion = ctx.getLatestVersion;
105
105
  var setLatestVersion = ctx.setLatestVersion;
106
106
  var loadContextSources = ctx.loadContextSources;
107
- var saveContextSources = ctx.saveContextSources;
108
107
 
109
108
  // Resolve the active user's Claude open-mode preference ('gui' or 'tui').
110
109
  // Multi-user mode reads per-user storage; single-user mode falls back to
@@ -516,7 +515,7 @@ function attachSessions(ctx) {
516
515
  // Reuse an existing blank GUI session instead of stacking another
517
516
  // one. A vendor-less blank is acceptable: nothing has happened in it,
518
517
  // so stamping the requested vendor is equivalent to a fresh create.
519
- var reusable = sm.findReusableBlankSession({
518
+ var reusable = msg.forceNew === true ? null : sm.findReusableBlankSession({
520
519
  vendor: sessionOpts.vendor || null,
521
520
  ownerId: sessionOpts.ownerId || null,
522
521
  });
@@ -542,15 +541,6 @@ function attachSessions(ctx) {
542
541
  }
543
542
  send({ type: "last_vendor", vendor: msg.vendor });
544
543
  }
545
- // Apply project-level email defaults to new session
546
- if (typeof ctx._email === "object" && ctx._email.getEmailDefaults) {
547
- var emailDefaults = ctx._email.getEmailDefaults();
548
- if (emailDefaults.length > 0) {
549
- var defaultSources = emailDefaults.map(function (id) { return "email:" + id; });
550
- saveContextSources(slug, newSess.localId, defaultSources);
551
- sendTo(ws, { type: "context_sources_state", active: defaultSources });
552
- }
553
- }
554
544
  var nsPresKey = ws._clayUser ? ws._clayUser.id : "_default";
555
545
  if (!ws._clayPane) userPresence.setPresence(slug, nsPresKey, newSess.localId, null);
556
546
  if (usersModule.isMultiUser() && !ws._clayPane) {
@@ -57,6 +57,7 @@ function attachUserMessage(ctx) {
57
57
  var imagesDir = ctx.imagesDir;
58
58
 
59
59
  var onProcessingChanged = ctx.onProcessingChanged;
60
+ var gitAttribution = ctx.gitAttribution;
60
61
 
61
62
  var _loop = ctx._loop;
62
63
  var browserState = ctx.browserState;
@@ -293,6 +294,7 @@ function attachUserMessage(ctx) {
293
294
 
294
295
  var session = getSessionForWs(ws);
295
296
  if (!session) return true;
297
+ if (gitAttribution) gitAttribution.beginTurn(session);
296
298
 
297
299
  // Bind vendor to session on first message (if not already set)
298
300
  if (!session.vendor && msg.vendor) {