clay-server 4.3.0-beta.1 → 4.3.0-beta.10

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 (67) hide show
  1. package/lib/driver-continuation-pair.js +5 -1
  2. package/lib/multi-worker-feature.js +11 -0
  3. package/lib/project-connection.js +3 -1
  4. package/lib/project-message-delivery.js +20 -0
  5. package/lib/project-pair-autonomous-stop.js +14 -8
  6. package/lib/project-pair-close-transaction.js +59 -0
  7. package/lib/project-pair-global-stop.js +18 -0
  8. package/lib/project-pair-lifecycle-status.js +34 -0
  9. package/lib/project-pair-lifecycle.js +58 -76
  10. package/lib/project-pair-message.js +1 -1
  11. package/lib/project-pair-owned-stop.js +7 -4
  12. package/lib/project-pair-result-capture.js +116 -0
  13. package/lib/project-pair-result-delivery.js +140 -0
  14. package/lib/project-pair-result-outbox.js +400 -0
  15. package/lib/project-pair-result-pipeline.js +16 -0
  16. package/lib/project-pair-result-recovery.js +229 -0
  17. package/lib/project-pair-structural-close.js +20 -0
  18. package/lib/project-pair-task-control.js +45 -18
  19. package/lib/project-pair-worker-close.js +68 -0
  20. package/lib/project-session-pair-prompt.js +26 -0
  21. package/lib/project-session-pair.js +87 -106
  22. package/lib/project-sessions.js +4 -0
  23. package/lib/project-user-message.js +7 -0
  24. package/lib/project-worker-creation-proposal.js +176 -0
  25. package/lib/project-worker-permission.js +7 -8
  26. package/lib/project-worker-proposal-runner.js +45 -0
  27. package/lib/project-worker-proposal.js +79 -115
  28. package/lib/project.js +38 -1
  29. package/lib/public/app.js +1 -0
  30. package/lib/public/css/messages.css +12 -0
  31. package/lib/public/css/mobile-nav.css +29 -14
  32. package/lib/public/css/overlays.css +22 -21
  33. package/lib/public/css/pane.css +64 -14
  34. package/lib/public/css/sidebar.css +62 -7
  35. package/lib/public/index.html +3 -3
  36. package/lib/public/modules/app-connection.js +165 -74
  37. package/lib/public/modules/app-messages.js +17 -3
  38. package/lib/public/modules/message-delivery-ui.js +63 -0
  39. package/lib/public/modules/message-delivery.js +163 -6
  40. package/lib/public/modules/pair-result-status.js +87 -0
  41. package/lib/public/modules/permission-control.js +5 -5
  42. package/lib/public/modules/project-activation.js +10 -7
  43. package/lib/public/modules/session-creation-cta.js +46 -0
  44. package/lib/public/modules/sidebar-mobile.js +42 -30
  45. package/lib/public/modules/sidebar-sessions.js +77 -52
  46. package/lib/public/modules/split-group-helpers.js +74 -2
  47. package/lib/public/modules/split-pair-ui.js +28 -6
  48. package/lib/public/modules/split-pane-reconciler.js +28 -0
  49. package/lib/public/modules/split-pane-renderer.js +107 -0
  50. package/lib/public/modules/split-view.js +58 -188
  51. package/lib/public/modules/websocket-lifecycle.js +178 -0
  52. package/lib/public/modules/websocket-watchdog.js +126 -0
  53. package/lib/public/modules/worker-pane-lock.js +13 -3
  54. package/lib/server.js +1 -0
  55. package/lib/session-pair-factory.js +66 -0
  56. package/lib/session-pair-mcp-server.js +10 -5
  57. package/lib/session-pair-prompts.js +41 -25
  58. package/lib/session-pair-target.js +42 -0
  59. package/lib/session-pair-turn-control.js +11 -6
  60. package/lib/session-split-group-anchors.js +89 -7
  61. package/lib/session-split-group-persistence.js +136 -0
  62. package/lib/session-split-group-roles.js +164 -0
  63. package/lib/session-split-group-v2-store.js +146 -0
  64. package/lib/session-split-groups.js +109 -99
  65. package/lib/sessions.js +70 -21
  66. package/lib/ws-schema.js +2 -0
  67. package/package.json +1 -1
@@ -1,7 +1,9 @@
1
1
  var crypto = require("crypto");
2
- var fs = require("fs");
3
- var path = require("path");
4
2
  var durableAnchors = require("./session-split-group-anchors");
3
+ var splitRoles = require("./session-split-group-roles");
4
+ var versionedStore = require("./session-split-group-v2-store");
5
+ var multiWorkerFeature = require("./multi-worker-feature");
6
+ var groupPersistence = require("./session-split-group-persistence");
5
7
  function truncateTitle(title) {
6
8
  var value = String(title || "New Session");
7
9
  return value.length > 20 ? value.slice(0, 19) + "…" : value;
@@ -11,92 +13,14 @@ function autoGroupName(leftTitle, rightTitle) {
11
13
  }
12
14
  function createSplitGroupStore(opts) {
13
15
  var sessions = opts.sessions;
14
- var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
15
16
  var usersModule = opts.usersModule;
16
17
  var broadcast = opts.broadcast || function () {};
17
18
  var onPairChanged = opts.onPairChanged || function () {};
18
19
  var groups = [];
19
20
  var stagedTransfers = [];
20
- function refreshPairAnchors(group) {
21
- if (!group.pair) return;
22
- var anchors = durableAnchors.pairAnchors(group, sessions);
23
- group.pairCliIds = anchors.cli;
24
- group.pairOriginIds = anchors.origin;
25
- }
26
- function save() {
27
- for (var gi = 0; gi < groups.length; gi++) {
28
- var anchors = durableAnchors.memberAnchors(groups[gi], sessions);
29
- groups[gi].memberCliIds = anchors.cli;
30
- groups[gi].memberOriginIds = anchors.origin;
31
- refreshPairAnchors(groups[gi]);
32
- }
33
- var serialized = JSON.stringify(groups, null, 2) + "\n";
34
- if (typeof opts.persistGroups === "function") {
35
- if (opts.persistGroups(groupsFile, serialized) === false) throw new Error("Split group persistence returned false");
36
- return;
37
- }
38
- var tmp = groupsFile + ".tmp." + process.pid;
39
- fs.mkdirSync(opts.sessionsDir, { recursive: true });
40
- fs.writeFileSync(tmp, serialized);
41
- if (process.platform !== "win32") {
42
- try { fs.chmodSync(tmp, 0o600); } catch (e) {}
43
- }
44
- fs.renameSync(tmp, groupsFile);
45
- }
46
- function validRecord(group) {
47
- return group && typeof group.id === "string" && Array.isArray(group.members) &&
48
- group.members.length === 2 && group.members[0] !== group.members[1] &&
49
- Number.isInteger(group.members[0]) && Number.isInteger(group.members[1]) &&
50
- sessions.has(group.members[0]) && sessions.has(group.members[1]);
51
- }
52
- function load() {
53
- var loaded = [];
54
- var shouldRewrite = false;
55
- try {
56
- loaded = JSON.parse(fs.readFileSync(groupsFile, "utf8"));
57
- if (!Array.isArray(loaded)) loaded = [];
58
- } catch (e) {
59
- if (e.code !== "ENOENT") shouldRewrite = true;
60
- }
61
- var claimed = {};
62
- groups = loaded.filter(function (group) {
63
- // Remap members from the durable cliSessionIds; the stored localIds
64
- // are only trusted for legacy records that predate memberCliIds.
65
- if (group && (Array.isArray(group.memberCliIds) || Array.isArray(group.memberOriginIds))) {
66
- var memberCliIds = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
67
- var memberOriginIds = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
68
- var left = durableAnchors.findSession(sessions, memberCliIds[0], memberOriginIds[0]);
69
- var right = durableAnchors.findSession(sessions, memberCliIds[1], memberOriginIds[1]);
70
- if (!left || !right || left === right) return false;
71
- if (group.members[0] !== left.localId || group.members[1] !== right.localId) {
72
- group.members = [left.localId, right.localId];
73
- shouldRewrite = true;
74
- }
75
- }
76
- if (group && group.pair && (Array.isArray(group.pairCliIds) || Array.isArray(group.pairOriginIds))) {
77
- var pairCliIds = Array.isArray(group.pairCliIds) ? group.pairCliIds : [null, null];
78
- var pairOriginIds = Array.isArray(group.pairOriginIds) ? group.pairOriginIds : [null, null];
79
- var driver = durableAnchors.findSession(sessions, pairCliIds[0], pairOriginIds[0]);
80
- var worker = durableAnchors.findSession(sessions, pairCliIds[1], pairOriginIds[1]);
81
- if (driver && worker) {
82
- group.pair.driverId = driver.localId;
83
- group.pair.workerId = worker.localId;
84
- }
85
- }
86
- if (group && group.pair) {
87
- var pairValid = Array.isArray(group.members) && group.pair.driverId !== group.pair.workerId &&
88
- group.members.indexOf(group.pair.driverId) !== -1 &&
89
- group.members.indexOf(group.pair.workerId) !== -1;
90
- if (!pairValid) return false;
91
- }
92
- if (!validRecord(group) || claimed[group.members[0]] || claimed[group.members[1]]) return false;
93
- claimed[group.members[0]] = true;
94
- claimed[group.members[1]] = true;
95
- return true;
96
- });
97
- if (groups.length !== loaded.length) shouldRewrite = true;
98
- if (shouldRewrite) save();
99
- }
21
+ var versionedEnabled = multiWorkerFeature.isEnabled(opts.multiWorkerFeature);
22
+ var persistence = groupPersistence.attachPersistence(Object.assign({}, opts, { versionedEnabled: versionedEnabled }));
23
+ function save() { return persistence.save(groups); }
100
24
  function isMultiUser() {
101
25
  return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
102
26
  }
@@ -153,6 +77,8 @@ function createSplitGroupStore(opts) {
153
77
  if (!left || !right) return { ok: false, error: "Session not found" };
154
78
  if (!canAccess(ws, left) || !canAccess(ws, right)) return { ok: false, error: "Session access denied" };
155
79
  if (membershipGroup(members[0]) || membershipGroup(members[1])) return { ok: false, error: "A session can belong to only one split group" };
80
+ var roleCheck = splitRoles.validateStorePair(msg.pair, members);
81
+ if (!roleCheck.ok) return { ok: false, error: roleCheck.error };
156
82
  var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
157
83
  var group = {
158
84
  id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
@@ -183,7 +109,8 @@ function createSplitGroupStore(opts) {
183
109
  if ((left.ownerId || null) !== (ownerId || null) || (right.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Session owner does not match the scheduled task owner" };
184
110
  if (membershipGroup(left.localId) || membershipGroup(right.localId)) return { ok: false, error: "A session can belong to only one split group" };
185
111
  var pair = msg && msg.pair;
186
- if (!pair || pair.driverId !== left.localId || pair.workerId !== right.localId) return { ok: false, error: "Pair roles must reference both split group members" };
112
+ var roleCheck = splitRoles.validateStorePair(pair, members);
113
+ if (!roleCheck.ok || !pair || pair.driverId !== left.localId || pair.workerId !== right.localId) return { ok: false, error: roleCheck.error || "Pair roles must reference both split group members" };
187
114
  var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
188
115
  var group = {
189
116
  id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
@@ -206,12 +133,13 @@ function createSplitGroupStore(opts) {
206
133
  onPairChanged(removed);
207
134
  return true;
208
135
  }
209
- // Runtime pair lookup sees this isolated projection while ordinary list,
210
- // save, anchor refresh, broadcast, and unrelated mutations keep using the
211
- // durable source-owned record until commit.
212
136
  function beginOwnedDriverTransfer(ownerId, msg) {
213
137
  var group = groups.find(function (item) { return item.id === (msg && msg.id); });
214
138
  if (!group || (group.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Split group not found" };
139
+ var transferRoles = splitRoles.normalizePair(group.pair, group.members);
140
+ if (transferRoles.ok && transferRoles.kind === "versioned") {
141
+ return { ok: false, error: "Version 2 Driver transfer is not enabled" };
142
+ }
215
143
  if (!group.pair || group.pair.driverId !== msg.sourceDriverId || group.pair.workerId !== msg.workerId) {
216
144
  return { ok: false, error: "The split pair changed before Driver transfer" };
217
145
  }
@@ -391,6 +319,8 @@ function createSplitGroupStore(opts) {
391
319
  return { ok: false, error: "Driver must be a member of the split group" };
392
320
  }
393
321
  var workerId = group.members[0] === msg.driverId ? group.members[1] : group.members[0];
322
+ var roleCheck = splitRoles.validateStorePair({ driverId: msg.driverId, workerId: workerId }, group.members);
323
+ if (!roleCheck.ok) return { ok: false, error: roleCheck.error };
394
324
  group.pair = { driverId: msg.driverId, workerId: workerId };
395
325
  save();
396
326
  broadcast();
@@ -398,22 +328,57 @@ function createSplitGroupStore(opts) {
398
328
  return { ok: true, group: group };
399
329
  }
400
330
 
401
- function dissolve(ws, msg) {
331
+ function dissolve(ws, msg, hooks) {
402
332
  var index = groups.findIndex(function (item) { return item.id === (msg && msg.id); });
403
333
  if (index === -1) return { ok: false, error: "Split group not found" };
404
334
  if (!canOwn(ws, groups[index])) return { ok: false, error: "Only the group owner can separate it" };
405
335
  var removed = groups.splice(index, 1)[0];
406
- save();
336
+ try { save(); }
337
+ catch (error) { groups.splice(index, 0, removed); return { ok: false, error: error.message || String(error) }; }
338
+ if (hooks && typeof hooks.afterPersist === "function") {
339
+ groups.splice(index, 0, removed);
340
+ var accepted;
341
+ try { accepted = hooks.afterPersist(); }
342
+ catch (error) { accepted = { ok: false, error: error.message || String(error) }; }
343
+ if (!accepted || accepted.ok === false) {
344
+ try { save(); }
345
+ catch (rollbackError) {
346
+ return { ok: false, error: (accepted && accepted.error || "close transaction rejected") +
347
+ "; persistence rollback failed: " + (rollbackError.message || String(rollbackError)) };
348
+ }
349
+ return { ok: false, error: accepted && accepted.error || "close transaction rejected" };
350
+ }
351
+ groups.splice(index, 1);
352
+ }
407
353
  broadcast();
408
354
  return { ok: true, group: removed };
409
355
  }
410
356
 
411
357
  function dissolveBySession(localId) {
412
- var before = groups.length;
413
- groups = groups.filter(function (group) { return group.members.indexOf(localId) === -1; });
414
- if (groups.length === before) return false;
415
- save();
358
+ var index = groups.findIndex(function (group) { return group.members.indexOf(localId) !== -1; });
359
+ if (index === -1) return false;
360
+ var group = groups[index];
361
+ var normalized = splitRoles.normalizePair(group.pair, group.members);
362
+ var beforeMembers = group.members.slice();
363
+ var beforePair = group.pair && Object.assign({}, group.pair);
364
+ if (beforePair && Array.isArray(beforePair.workerIds)) beforePair.workerIds = beforePair.workerIds.slice();
365
+ var removed = null;
366
+ if (normalized.ok && normalized.kind === "versioned" && normalized.driverId !== localId &&
367
+ normalized.workerIds.length > 1) {
368
+ var remaining = normalized.workerIds.filter(function (id) { return id !== localId; });
369
+ group.members = [normalized.driverId].concat(remaining);
370
+ group.pair = { version: 2, driverId: normalized.driverId, workerIds: remaining };
371
+ } else {
372
+ removed = groups.splice(index, 1)[0];
373
+ }
374
+ try { save(); }
375
+ catch (error) {
376
+ if (removed) groups.splice(index, 0, removed);
377
+ else { group.members = beforeMembers; group.pair = beforePair; }
378
+ return false;
379
+ }
416
380
  broadcast();
381
+ onPairChanged(group);
417
382
  return true;
418
383
  }
419
384
 
@@ -421,6 +386,11 @@ function createSplitGroupStore(opts) {
421
386
  var group = groupForMember(localId);
422
387
  if (!group) return false;
423
388
  var fresh = durableAnchors.memberAnchors(group, sessions);
389
+ if (fresh && fresh.version === 2) {
390
+ if (JSON.stringify(fresh) === JSON.stringify(group.memberAnchors || null)) return false;
391
+ save();
392
+ return true;
393
+ }
424
394
  var prevCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
425
395
  var prevOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
426
396
  if (fresh.cli[0] === prevCli[0] && fresh.cli[1] === prevCli[1] &&
@@ -445,11 +415,54 @@ function createSplitGroupStore(opts) {
445
415
  return true;
446
416
  }
447
417
 
448
- load();
418
+ function removeVersionedGroup(group, hooks) {
419
+ var index = groups.indexOf(group);
420
+ if (index === -1) return { ok: false, error: "Split group not found" };
421
+ groups.splice(index, 1);
422
+ try { save(); }
423
+ catch (error) {
424
+ groups.splice(index, 0, group);
425
+ return { ok: false, error: error.message || String(error) };
426
+ }
427
+ if (hooks && typeof hooks.afterPersist === "function") {
428
+ groups.splice(index, 0, group);
429
+ var accepted;
430
+ try { accepted = hooks.afterPersist(); }
431
+ catch (error) { accepted = { ok: false, error: error.message || String(error) }; }
432
+ if (!accepted || accepted.ok === false) {
433
+ try { save(); }
434
+ catch (rollbackError) {
435
+ return { ok: false, error: (accepted && accepted.error || "close transaction rejected") +
436
+ "; persistence rollback failed: " + (rollbackError.message || String(rollbackError)) };
437
+ }
438
+ return { ok: false, error: accepted && accepted.error || "close transaction rejected" };
439
+ }
440
+ groups.splice(index, 1);
441
+ }
442
+ broadcast();
443
+ onPairChanged(group);
444
+ return { ok: true, group: group };
445
+ }
446
+ function versionedChanged(group) {
447
+ broadcast();
448
+ onPairChanged(group);
449
+ }
450
+ var versionedMutations = versionedStore.attachVersionedMutations({
451
+ sessions: sessions,
452
+ enabled: versionedEnabled,
453
+ groups: function () { return groups; },
454
+ membershipGroup: membershipGroup,
455
+ save: save,
456
+ remove: removeVersionedGroup,
457
+ changed: versionedChanged,
458
+ });
459
+ groups = persistence.load();
449
460
  return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
450
461
  beginOwnedDriverTransfer: beginOwnedDriverTransfer, commitOwnedDriverTransfer: commitOwnedDriverTransfer,
451
462
  rollbackOwnedDriverTransfer: rollbackOwnedDriverTransfer,
452
463
  rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
464
+ addWorker: versionedMutations.addWorker, replaceWorker: versionedMutations.replaceWorker,
465
+ removeWorker: versionedMutations.removeWorker,
453
466
  refreshAutoName: refreshAutoName, refreshAnchors: refreshAnchors,
454
467
  listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
455
468
  }
@@ -461,13 +474,11 @@ function attachSplitGroups(ctx) {
461
474
  usersModule: ctx.usersModule,
462
475
  broadcast: broadcast,
463
476
  onPairChanged: ctx.onPairChanged,
477
+ multiWorkerFeature: ctx.multiWorkerFeature,
464
478
  });
465
479
 
466
480
  function sendState(ws) {
467
481
  if (!ws) return;
468
- // Pane clients need the same owner-scoped pair roles as the parent shell.
469
- // The Worker composer lock resolves its role from this projection; hiding
470
- // the projection from panes leaves a configured Worker looking editable.
471
482
  ctx.sendTo(ws, { type: "split_groups", groups: store.listFor(ws) });
472
483
  }
473
484
 
@@ -495,5 +506,4 @@ function attachSplitGroups(ctx) {
495
506
  return { handleMessage: handleMessage, sendConnectionState: sendState, store: store };
496
507
  }
497
508
 
498
- module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore,
499
- autoGroupName: autoGroupName };
509
+ module.exports = { attachSplitGroups: attachSplitGroups, createSplitGroupStore: createSplitGroupStore, autoGroupName: autoGroupName };
package/lib/sessions.js CHANGED
@@ -144,17 +144,32 @@ function createSessionManager(opts) {
144
144
  }
145
145
  }
146
146
 
147
- function sessionFilePath(cliSessionId) {
148
- return path.join(sessionsDir, cliSessionId + ".jsonl");
147
+ function sessionFilePath(recordId) {
148
+ return path.join(sessionsDir, recordId + ".jsonl");
149
+ }
150
+
151
+ // A Clay record must exist before a provider assigns its resumable id.
152
+ // Existing records keep their filename; fresh provider-less sessions use
153
+ // the already durable origin id and later store cliSessionId as metadata.
154
+ function sessionRecordId(session) {
155
+ if (!session) return null;
156
+ return session._sessionRecordId || session.cliSessionId || session.sessionOriginId || null;
157
+ }
158
+
159
+ function existingSessionRecordId(session) {
160
+ if (!session) return null;
161
+ return session._sessionRecordId || session.cliSessionId || null;
149
162
  }
150
163
 
151
164
  function saveSessionFile(session) {
152
- if (!session.cliSessionId) return false;
153
165
  try {
166
+ var provenanceMeta = sessionProvenance.metadata(session);
167
+ var recordId = sessionRecordId(session);
168
+ if (!recordId) return false;
154
169
  var metaObj = {
155
170
  type: "meta",
156
171
  localId: session.localId,
157
- cliSessionId: session.cliSessionId,
172
+ cliSessionId: session.cliSessionId || null,
158
173
  title: session.title,
159
174
  titleProvisional: session.titleProvisional === true,
160
175
  titleAutoGenerated: session.titleAutoGenerated === true,
@@ -204,7 +219,6 @@ function createSessionManager(opts) {
204
219
  if (session.homeDebateInitialTopic) metaObj.homeDebateInitialTopic = session.homeDebateInitialTopic;
205
220
  if (session.homeClayEntryRequestId) metaObj.homeClayEntryRequestId = session.homeClayEntryRequestId;
206
221
  if (session.homeClayEntryMode) metaObj.homeClayEntryMode = session.homeClayEntryMode;
207
- var provenanceMeta = sessionProvenance.metadata(session);
208
222
  metaObj.sessionOriginId = provenanceMeta.sessionOriginId;
209
223
  if (provenanceMeta.sessionProvenance) metaObj.sessionProvenance = provenanceMeta.sessionProvenance;
210
224
  if (Array.isArray(session._workerGenerations)) metaObj.workerGenerations = session._workerGenerations;
@@ -213,7 +227,7 @@ function createSessionManager(opts) {
213
227
  for (var i = 0; i < session.history.length; i++) {
214
228
  lines.push(JSON.stringify(session.history[i]));
215
229
  }
216
- var sfPath = sessionFilePath(session.cliSessionId);
230
+ var sfPath = sessionFilePath(recordId);
217
231
  // Atomic write: write to temp file then rename, so a crash mid-write
218
232
  // cannot leave a truncated/corrupted session file.
219
233
  var tmpPath = sfPath + ".tmp." + process.pid;
@@ -222,6 +236,7 @@ function createSessionManager(opts) {
222
236
  try { fs.chmodSync(tmpPath, 0o600); } catch (chmodErr) {}
223
237
  }
224
238
  fs.renameSync(tmpPath, sfPath);
239
+ session._sessionRecordId = recordId;
225
240
  return true;
226
241
  } catch(e) {
227
242
  console.error("[session] Failed to save session file:", e.message);
@@ -230,16 +245,19 @@ function createSessionManager(opts) {
230
245
  }
231
246
 
232
247
  function appendToSessionFile(session, obj) {
233
- if (!session.cliSessionId) return;
248
+ var recordId = existingSessionRecordId(session);
249
+ if (!recordId) return true;
234
250
  session.lastActivity = Date.now();
235
251
  try {
236
- var afPath = sessionFilePath(session.cliSessionId);
252
+ var afPath = sessionFilePath(recordId);
237
253
  fs.appendFileSync(afPath, JSON.stringify(obj) + "\n");
238
254
  if (process.platform !== "win32") {
239
255
  try { fs.chmodSync(afPath, 0o600); } catch (chmodErr) {}
240
256
  }
257
+ return true;
241
258
  } catch(e) {
242
259
  console.error("[session] Failed to append to session file:", e.message);
260
+ return false;
243
261
  }
244
262
  }
245
263
 
@@ -274,7 +292,8 @@ function createSessionManager(opts) {
274
292
 
275
293
  var meta;
276
294
  try { meta = JSON.parse(lines[0]); } catch { continue; }
277
- if (meta.type !== "meta" || !meta.cliSessionId) continue;
295
+ var pendingOrigin = typeof meta.sessionOriginId === "string" && meta.sessionOriginId.trim() && meta.sessionOriginId.length <= 128;
296
+ if (meta.type !== "meta" || (!meta.cliSessionId && !pendingOrigin)) continue;
278
297
 
279
298
  var history = [];
280
299
  for (var j = 1; j < lines.length; j++) {
@@ -283,7 +302,7 @@ function createSessionManager(opts) {
283
302
 
284
303
  var fileMtime = 0;
285
304
  try { fileMtime = fs.statSync(path.join(sessionsDir, files[i])).mtimeMs; } catch {}
286
- loaded.push({ meta: meta, history: history, mtime: fileMtime });
305
+ loaded.push({ meta: meta, history: history, mtime: fileMtime, recordId: files[i].slice(0, -".jsonl".length) });
287
306
  }
288
307
 
289
308
  loaded.sort(function(a, b) { return a.meta.createdAt - b.meta.createdAt; });
@@ -308,6 +327,7 @@ function createSessionManager(opts) {
308
327
  queryInstance: null,
309
328
  messageQueue: null,
310
329
  cliSessionId: m.cliSessionId,
330
+ _sessionRecordId: loaded[i].recordId,
311
331
  blocks: {},
312
332
  sentToolResults: {},
313
333
  pendingPermissions: {},
@@ -386,7 +406,7 @@ function createSessionManager(opts) {
386
406
  saveSessionFile(session);
387
407
  try {
388
408
  var preservedTime = new Date(activityTime);
389
- fs.utimesSync(sessionFilePath(session.cliSessionId), preservedTime, preservedTime);
409
+ fs.utimesSync(sessionFilePath(sessionRecordId(session)), preservedTime, preservedTime);
390
410
  } catch (e) {}
391
411
  delete session._provenanceMigrationNeeded;
392
412
  });
@@ -846,7 +866,7 @@ function createSessionManager(opts) {
846
866
  var _sessionVendor = session.vendor || defaultVendor || "claude";
847
867
  var _vendorCaps = _capsByVendor[_sessionVendor] || {};
848
868
  var _permissionState = require("./session-permission-mode").clientState(session, "default", defaultVendor);
849
- _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
869
+ _send({ type: "session_switched", id: localId, sessionOriginId: session.sessionOriginId || null, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
850
870
  // Send vendor-specific slash commands
851
871
  var _vendorCmds = (session.slashCommandsByVendor && session.slashCommandsByVendor[_sessionVendor])
852
872
  || session.slashCommands
@@ -915,10 +935,9 @@ function createSessionManager(opts) {
915
935
  session.worker = null;
916
936
  }
917
937
 
918
- if (session.cliSessionId) {
919
- rememberDeletedCliSession(session.cliSessionId);
920
- try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
921
- }
938
+ if (session.cliSessionId) rememberDeletedCliSession(session.cliSessionId);
939
+ var recordId = existingSessionRecordId(session);
940
+ if (recordId) try { fs.unlinkSync(sessionFilePath(recordId)); } catch(e) {}
922
941
 
923
942
  sessions.delete(localId);
924
943
  try { onSessionDeleted(localId, session); } catch (e) {
@@ -962,10 +981,9 @@ function createSessionManager(opts) {
962
981
  try { session.worker.kill(); } catch(e) {}
963
982
  session.worker = null;
964
983
  }
965
- if (session.cliSessionId) {
966
- rememberDeletedCliSession(session.cliSessionId);
967
- try { fs.unlinkSync(sessionFilePath(session.cliSessionId)); } catch(e) {}
968
- }
984
+ if (session.cliSessionId) rememberDeletedCliSession(session.cliSessionId);
985
+ var recordId = existingSessionRecordId(session);
986
+ if (recordId) try { fs.unlinkSync(sessionFilePath(recordId)); } catch(e) {}
969
987
  sessions.delete(localId);
970
988
  try { onSessionDeleted(localId, session); } catch (e) {
971
989
  console.error("[session] Session deletion hook failed:", e.message || e);
@@ -1031,7 +1049,7 @@ function createSessionManager(opts) {
1031
1049
  var outgoingObj = clientObj || obj;
1032
1050
  if (!outgoingObj._ts) outgoingObj._ts = obj._ts;
1033
1051
  session.history.push(obj);
1034
- appendToSessionFile(session, obj);
1052
+ if (appendToSessionFile(session, obj) === false) return false;
1035
1053
  // Per-session out-of-band subscribers mirror mate session events into
1036
1054
  // a parallel UI without joining the project's
1037
1055
  // ws clients set). Subscribers receive the same obj that goes to ws
@@ -1087,6 +1105,35 @@ function createSessionManager(opts) {
1087
1105
  }
1088
1106
  // Notify server for cross-project unread tracking
1089
1107
  if (obj.type === "done") onSessionDone(session);
1108
+ return true;
1109
+ }
1110
+
1111
+ function sendAndRecordDurably(session, obj, clientObj) {
1112
+ var recordId = existingSessionRecordId(session);
1113
+ if (!recordId || !fs.existsSync(sessionFilePath(recordId))) return false;
1114
+ var saved;
1115
+ try { saved = doSendAndRecord(session, obj, clientObj); }
1116
+ catch (error) {
1117
+ var exact = JSON.stringify(obj);
1118
+ if (hasDurableSessionRecord(session, function (record) { return JSON.stringify(record) === exact; })) return true;
1119
+ if (session.history[session.history.length - 1] === obj) session.history.pop();
1120
+ throw error;
1121
+ }
1122
+ if (saved === false && session.history[session.history.length - 1] === obj) session.history.pop();
1123
+ return saved === true;
1124
+ }
1125
+
1126
+ function hasDurableSessionRecord(session, predicate) {
1127
+ var recordId = existingSessionRecordId(session);
1128
+ if (!recordId || typeof predicate !== "function") return false;
1129
+ try {
1130
+ var lines = fs.readFileSync(sessionFilePath(recordId), "utf8").split("\n");
1131
+ for (var i = lines.length - 1; i >= 0; i--) {
1132
+ if (!lines[i]) continue;
1133
+ try { if (predicate(JSON.parse(lines[i]))) return true; } catch (error) {}
1134
+ }
1135
+ } catch (error) {}
1136
+ return false;
1090
1137
  }
1091
1138
 
1092
1139
  function resumeSession(cliSessionId, opts, targetWs) {
@@ -1384,6 +1431,8 @@ function createSessionManager(opts) {
1384
1431
  saveSessionFile: saveSessionFile,
1385
1432
  appendToSessionFile: appendToSessionFile,
1386
1433
  sendAndRecord: doSendAndRecord,
1434
+ sendAndRecordDurably: sendAndRecordDurably,
1435
+ hasDurableSessionRecord: hasDurableSessionRecord,
1387
1436
  subscribeSession: function (localId, cb) {
1388
1437
  var session = sessions.get(localId);
1389
1438
  if (!session) return null;
package/lib/ws-schema.js CHANGED
@@ -41,6 +41,8 @@ var schema = {
41
41
  "split_group_rename": { direction: "c2s", handler: "lib/session-split-groups.js", description: "Rename a split group" },
42
42
  "split_group_dissolve": { direction: "c2s", handler: "lib/session-split-groups.js", description: "Separate a split group without deleting its sessions" },
43
43
  "split_group_set_pair": { direction: "c2s", handler: "lib/session-split-groups.js", description: "Assign Driver/Worker roles on a split group (driverId null clears them)" },
44
+ "split_worker_close": { direction: "c2s", handler: "lib/project-pair-worker-close.js", description: "Close one exact authenticated Worker generation while preserving its peer and session history" },
45
+ "split_worker_close_result": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Correlated result of an exact targeted Worker close" },
44
46
  "pair_session_options": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Request vendors/models for the pair-session dialog (response reuses the same type)" },
45
47
  "pair_session_create": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Create a Driver/Split Worker session pair as a split group" },
46
48
  "worker_proposal_response": { direction: "c2s", handler: "lib/project-worker-proposal.js", description: "Accept or decline an exact Driver Split Worker configuration with the selected runtime" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.3.0-beta.1",
3
+ "version": "4.3.0-beta.10",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"