clay-server 4.2.0 → 4.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/lib/driver-continuation-access.js +54 -0
  2. package/lib/driver-continuation-lease.js +52 -0
  3. package/lib/driver-continuation-pair-transfer.js +86 -0
  4. package/lib/driver-continuation-pair.js +82 -0
  5. package/lib/driver-continuation-record.js +40 -0
  6. package/lib/driver-continuation-startup.js +68 -0
  7. package/lib/driver-continuation-transaction.js +61 -0
  8. package/lib/driver-continuation-trigger.js +110 -0
  9. package/lib/project-driver-continuation.js +499 -0
  10. package/lib/project-pair-lifecycle.js +73 -73
  11. package/lib/project-pair-replacement-state.js +5 -1
  12. package/lib/project-session-handoff.js +48 -55
  13. package/lib/project-user-message.js +14 -0
  14. package/lib/project-worker-proposal.js +1 -1
  15. package/lib/project.js +49 -2
  16. package/lib/public/css/driver-continuation.css +56 -0
  17. package/lib/public/css/overlays.css +44 -16
  18. package/lib/public/css/pane.css +9 -5
  19. package/lib/public/index.html +3 -3
  20. package/lib/public/modules/app-messages.js +14 -0
  21. package/lib/public/modules/driver-continuation-state.js +76 -0
  22. package/lib/public/modules/driver-continuation.js +260 -0
  23. package/lib/public/modules/permission-control.js +8 -3
  24. package/lib/public/style.css +1 -0
  25. package/lib/sdk-bridge.js +77 -0
  26. package/lib/session-handoff-discovery.js +443 -0
  27. package/lib/session-handoff-mcp-server.js +32 -4
  28. package/lib/session-pair-prompts.js +5 -0
  29. package/lib/session-pair-turn-control.js +19 -3
  30. package/lib/session-split-group-anchors.js +44 -0
  31. package/lib/session-split-groups.js +209 -59
  32. package/lib/sessions.js +20 -2
  33. package/lib/ws-schema.js +6 -0
  34. package/package.json +1 -1
@@ -8,6 +8,7 @@
8
8
 
9
9
  var MAX_CREATIONS_PER_TURN = 2;
10
10
  var MAX_REPLACEMENTS_PER_TURN = 1;
11
+ var MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN = 2;
11
12
  var MAX_OPERATION_ID_CHARS = 120;
12
13
 
13
14
  function attachPairTurnControl(ctx) {
@@ -23,9 +24,13 @@ function attachPairTurnControl(ctx) {
23
24
  stoppedWorkerId: null,
24
25
  creations: 0,
25
26
  replacements: 0,
27
+ failedReplacementAttempts: 0,
26
28
  operations: Object.create(null),
27
29
  };
28
30
  }
31
+ if (!Number.isFinite(driver._pairTurnControl.failedReplacementAttempts)) {
32
+ driver._pairTurnControl.failedReplacementAttempts = 0;
33
+ }
29
34
  return driver._pairTurnControl;
30
35
  }
31
36
 
@@ -56,6 +61,7 @@ function attachPairTurnControl(ctx) {
56
61
  state.stoppedWorkerId = null;
57
62
  state.creations = 0;
58
63
  state.replacements = 0;
64
+ state.failedReplacementAttempts = 0;
59
65
  state.operations = Object.create(null);
60
66
  return true;
61
67
  }
@@ -88,20 +94,28 @@ function attachPairTurnControl(ctx) {
88
94
  if (kind === "replace" && state.replacements >= MAX_REPLACEMENTS_PER_TURN) {
89
95
  throw new Error("the Split Worker replacement limit for this human turn has been reached; wait for a new human message before replacing it again");
90
96
  }
97
+ if (kind === "replace" && state.failedReplacementAttempts >= MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN) {
98
+ throw new Error("the Split Worker replacement failure limit for this human turn has been reached; wait for a new human message before retrying replacement");
99
+ }
91
100
  if (state.creations >= MAX_CREATIONS_PER_TURN) {
92
101
  throw new Error("the Split Worker creation limit for this human turn has been reached; wait for a new human message before creating another Worker");
93
102
  }
94
103
  state.creations += 1;
95
104
  if (kind === "replace") state.replacements += 1;
96
- return { driver: driver, kind: kind, active: true };
105
+ return { driver: driver, kind: kind, turnSerial: state.serial, active: true };
97
106
  }
98
107
 
99
108
  function releaseCreation(ticket) {
100
- if (!ticket || !ticket.active || !liveSession(ticket.driver)) return;
109
+ if (!ticket || !ticket.active) return;
101
110
  ticket.active = false;
111
+ if (!liveSession(ticket.driver)) return;
102
112
  var state = stateFor(ticket.driver);
113
+ if (state.serial !== ticket.turnSerial) return;
103
114
  state.creations = Math.max(0, state.creations - 1);
104
- if (ticket.kind === "replace") state.replacements = Math.max(0, state.replacements - 1);
115
+ if (ticket.kind === "replace") {
116
+ state.replacements = Math.max(0, state.replacements - 1);
117
+ state.failedReplacementAttempts += 1;
118
+ }
105
119
  }
106
120
 
107
121
  function operationId(value) {
@@ -136,6 +150,7 @@ function attachPairTurnControl(ctx) {
136
150
  turnSerial: state.serial,
137
151
  creationsThisTurn: state.creations,
138
152
  replacementsThisTurn: state.replacements,
153
+ failedReplacementAttemptsThisTurn: state.failedReplacementAttempts,
139
154
  };
140
155
  }
141
156
 
@@ -154,5 +169,6 @@ function attachPairTurnControl(ctx) {
154
169
  module.exports = {
155
170
  MAX_CREATIONS_PER_TURN: MAX_CREATIONS_PER_TURN,
156
171
  MAX_REPLACEMENTS_PER_TURN: MAX_REPLACEMENTS_PER_TURN,
172
+ MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN: MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN,
157
173
  attachPairTurnControl: attachPairTurnControl,
158
174
  };
@@ -0,0 +1,44 @@
1
+ function value(input) {
2
+ return typeof input === "string" && input ? input : null;
3
+ }
4
+
5
+ function pairFor(session, previousCli, previousOrigin) {
6
+ return {
7
+ cli: value(session && session.cliSessionId) || value(previousCli),
8
+ origin: value(session && session.sessionOriginId) || value(previousOrigin),
9
+ };
10
+ }
11
+
12
+ function memberAnchors(group, sessions) {
13
+ var previousCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
14
+ var previousOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
15
+ var left = pairFor(sessions.get(group.members[0]), previousCli[0], previousOrigin[0]);
16
+ var right = pairFor(sessions.get(group.members[1]), previousCli[1], previousOrigin[1]);
17
+ return { cli: [left.cli, right.cli], origin: [left.origin, right.origin] };
18
+ }
19
+
20
+ function pairAnchors(group, sessions) {
21
+ if (!group.pair) return null;
22
+ var previousCli = Array.isArray(group.pairCliIds) ? group.pairCliIds : [null, null];
23
+ var previousOrigin = Array.isArray(group.pairOriginIds) ? group.pairOriginIds : [null, null];
24
+ var driver = pairFor(sessions.get(group.pair.driverId), previousCli[0], previousOrigin[0]);
25
+ var worker = pairFor(sessions.get(group.pair.workerId), previousCli[1], previousOrigin[1]);
26
+ return { cli: [driver.cli, worker.cli], origin: [driver.origin, worker.origin] };
27
+ }
28
+
29
+ function findSession(sessions, cliId, originId) {
30
+ var cli = value(cliId);
31
+ var origin = value(originId);
32
+ if (!cli && !origin) return null;
33
+ var found = null;
34
+ var ambiguous = false;
35
+ sessions.forEach(function (session) {
36
+ if (cli && session.cliSessionId !== cli) return;
37
+ if (origin && session.sessionOriginId !== origin) return;
38
+ if (found && found !== session) ambiguous = true;
39
+ else found = session;
40
+ });
41
+ return ambiguous ? null : found;
42
+ }
43
+
44
+ module.exports = { findSession: findSession, memberAnchors: memberAnchors, pairAnchors: pairAnchors };
@@ -1,16 +1,14 @@
1
1
  var crypto = require("crypto");
2
2
  var fs = require("fs");
3
3
  var path = require("path");
4
-
4
+ var durableAnchors = require("./session-split-group-anchors");
5
5
  function truncateTitle(title) {
6
6
  var value = String(title || "New Session");
7
7
  return value.length > 20 ? value.slice(0, 19) + "…" : value;
8
8
  }
9
-
10
9
  function autoGroupName(leftTitle, rightTitle) {
11
10
  return truncateTitle(leftTitle) + " | " + truncateTitle(rightTitle);
12
11
  }
13
-
14
12
  function createSplitGroupStore(opts) {
15
13
  var sessions = opts.sessions;
16
14
  var groupsFile = path.join(opts.sessionsDir, "split-groups.json");
@@ -18,61 +16,39 @@ function createSplitGroupStore(opts) {
18
16
  var broadcast = opts.broadcast || function () {};
19
17
  var onPairChanged = opts.onPairChanged || function () {};
20
18
  var groups = [];
21
-
22
- // localIds are reassigned on every daemon restart (loadSessions renumbers
23
- // by createdAt), so persisted member localIds go stale as soon as the
24
- // session set changes. cliSessionIds are the durable anchor: keep them on
25
- // every record and remap members from them at load.
26
- function currentCliIds(group) {
27
- var left = sessions.get(group.members[0]);
28
- var right = sessions.get(group.members[1]);
29
- var prev = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
30
- return [
31
- (left && left.cliSessionId) || prev[0] || null,
32
- (right && right.cliSessionId) || prev[1] || null,
33
- ];
34
- }
35
-
19
+ var stagedTransfers = [];
36
20
  function refreshPairAnchors(group) {
37
21
  if (!group.pair) return;
38
- var driver = sessions.get(group.pair.driverId);
39
- var worker = sessions.get(group.pair.workerId);
40
- group.pairCliIds = [
41
- (driver && driver.cliSessionId) || (group.pairCliIds && group.pairCliIds[0]) || null,
42
- (worker && worker.cliSessionId) || (group.pairCliIds && group.pairCliIds[1]) || null,
43
- ];
22
+ var anchors = durableAnchors.pairAnchors(group, sessions);
23
+ group.pairCliIds = anchors.cli;
24
+ group.pairOriginIds = anchors.origin;
44
25
  }
45
-
46
- function sessionByCliId(cliId) {
47
- if (!cliId) return null;
48
- var found = null;
49
- sessions.forEach(function (s) {
50
- if (s.cliSessionId === cliId) found = s;
51
- });
52
- return found;
53
- }
54
-
55
26
  function save() {
56
27
  for (var gi = 0; gi < groups.length; gi++) {
57
- groups[gi].memberCliIds = currentCliIds(groups[gi]);
28
+ var anchors = durableAnchors.memberAnchors(groups[gi], sessions);
29
+ groups[gi].memberCliIds = anchors.cli;
30
+ groups[gi].memberOriginIds = anchors.origin;
58
31
  refreshPairAnchors(groups[gi]);
59
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
+ }
60
38
  var tmp = groupsFile + ".tmp." + process.pid;
61
39
  fs.mkdirSync(opts.sessionsDir, { recursive: true });
62
- fs.writeFileSync(tmp, JSON.stringify(groups, null, 2) + "\n");
40
+ fs.writeFileSync(tmp, serialized);
63
41
  if (process.platform !== "win32") {
64
42
  try { fs.chmodSync(tmp, 0o600); } catch (e) {}
65
43
  }
66
44
  fs.renameSync(tmp, groupsFile);
67
45
  }
68
-
69
46
  function validRecord(group) {
70
47
  return group && typeof group.id === "string" && Array.isArray(group.members) &&
71
48
  group.members.length === 2 && group.members[0] !== group.members[1] &&
72
49
  Number.isInteger(group.members[0]) && Number.isInteger(group.members[1]) &&
73
50
  sessions.has(group.members[0]) && sessions.has(group.members[1]);
74
51
  }
75
-
76
52
  function load() {
77
53
  var loaded = [];
78
54
  var shouldRewrite = false;
@@ -86,18 +62,22 @@ function createSplitGroupStore(opts) {
86
62
  groups = loaded.filter(function (group) {
87
63
  // Remap members from the durable cliSessionIds; the stored localIds
88
64
  // are only trusted for legacy records that predate memberCliIds.
89
- if (group && Array.isArray(group.memberCliIds)) {
90
- var left = sessionByCliId(group.memberCliIds[0]);
91
- var right = sessionByCliId(group.memberCliIds[1]);
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]);
92
70
  if (!left || !right || left === right) return false;
93
71
  if (group.members[0] !== left.localId || group.members[1] !== right.localId) {
94
72
  group.members = [left.localId, right.localId];
95
73
  shouldRewrite = true;
96
74
  }
97
75
  }
98
- if (group && group.pair && Array.isArray(group.pairCliIds)) {
99
- var driver = sessionByCliId(group.pairCliIds[0]);
100
- var worker = sessionByCliId(group.pairCliIds[1]);
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]);
101
81
  if (driver && worker) {
102
82
  group.pair.driverId = driver.localId;
103
83
  group.pair.workerId = worker.localId;
@@ -117,35 +97,51 @@ function createSplitGroupStore(opts) {
117
97
  if (groups.length !== loaded.length) shouldRewrite = true;
118
98
  if (shouldRewrite) save();
119
99
  }
120
-
121
100
  function isMultiUser() {
122
101
  return !!(usersModule && usersModule.isMultiUser && usersModule.isMultiUser());
123
102
  }
124
-
125
103
  function listFor(ws) {
126
104
  if (!isMultiUser()) return groups.slice();
127
105
  if (!ws || !ws._clayUser) return [];
128
106
  return groups.filter(function (group) { return group.ownerId === ws._clayUser.id; });
129
107
  }
130
-
131
108
  function groupForMember(localId) {
109
+ for (var si = 0; si < stagedTransfers.length; si++) {
110
+ var transfer = stagedTransfers[si];
111
+ if (!exactTransfer(transfer)) continue;
112
+ if (transfer.source.localId === localId) return null;
113
+ if (transfer.target.localId === localId || transfer.worker.localId === localId) return transfer.runtimeGroup;
114
+ }
132
115
  for (var i = 0; i < groups.length; i++) {
133
116
  if (groups[i].members.indexOf(localId) !== -1) return groups[i];
134
117
  }
135
118
  return null;
136
119
  }
137
-
120
+ function membershipGroup(localId) {
121
+ for (var si = 0; si < stagedTransfers.length; si++) {
122
+ var transfer = stagedTransfers[si];
123
+ if (transfer.source.localId === localId || transfer.target.localId === localId ||
124
+ transfer.worker.localId === localId) return transfer.group;
125
+ }
126
+ for (var i = 0; i < groups.length; i++) if (groups[i].members.indexOf(localId) !== -1) return groups[i];
127
+ return null;
128
+ }
129
+ function groupCountForMember(localId) {
130
+ var count = 0;
131
+ for (var i = 0; i < groups.length; i++) {
132
+ if (groups[i].members.indexOf(localId) !== -1) count++;
133
+ }
134
+ return count;
135
+ }
138
136
  function canAccess(ws, session) {
139
137
  if (!isMultiUser()) return true;
140
138
  return !!(ws && ws._clayUser && usersModule.canAccessSession(
141
139
  ws._clayUser.id, session, { visibility: "public" }
142
140
  ));
143
141
  }
144
-
145
142
  function canOwn(ws, group) {
146
143
  return !isMultiUser() || !!(ws && ws._clayUser && group.ownerId === ws._clayUser.id);
147
144
  }
148
-
149
145
  function create(ws, msg) {
150
146
  var members = msg && msg.members;
151
147
  if (!Array.isArray(members) || members.length !== 2) return { ok: false, error: "A split group requires exactly two sessions" };
@@ -156,7 +152,7 @@ function createSplitGroupStore(opts) {
156
152
  var right = sessions.get(members[1]);
157
153
  if (!left || !right) return { ok: false, error: "Session not found" };
158
154
  if (!canAccess(ws, left) || !canAccess(ws, right)) return { ok: false, error: "Session access denied" };
159
- if (groupForMember(members[0]) || groupForMember(members[1])) return { ok: false, error: "A session can belong to only one split group" };
155
+ if (membershipGroup(members[0]) || membershipGroup(members[1])) return { ok: false, error: "A session can belong to only one split group" };
160
156
  var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
161
157
  var group = {
162
158
  id: "sg_" + Date.now().toString(36) + "_" + crypto.randomBytes(3).toString("hex"),
@@ -178,7 +174,6 @@ function createSplitGroupStore(opts) {
178
174
  if (group.pair) onPairChanged(group);
179
175
  return { ok: true, group: group };
180
176
  }
181
-
182
177
  function createOwned(ownerId, msg) {
183
178
  var members = msg && msg.members;
184
179
  if (!Array.isArray(members) || members.length !== 2) return { ok: false, error: "A split group requires exactly two sessions" };
@@ -186,7 +181,7 @@ function createSplitGroupStore(opts) {
186
181
  var right = sessions.get(members[1]);
187
182
  if (!left || !right || left === right) return { ok: false, error: "Session not found" };
188
183
  if ((left.ownerId || null) !== (ownerId || null) || (right.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Session owner does not match the scheduled task owner" };
189
- if (groupForMember(left.localId) || groupForMember(right.localId)) return { ok: false, error: "A session can belong to only one split group" };
184
+ if (membershipGroup(left.localId) || membershipGroup(right.localId)) return { ok: false, error: "A session can belong to only one split group" };
190
185
  var pair = msg && msg.pair;
191
186
  if (!pair || pair.driverId !== left.localId || pair.workerId !== right.localId) return { ok: false, error: "Pair roles must reference both split group members" };
192
187
  var requestedName = typeof msg.name === "string" ? msg.name.trim().slice(0, 80) : "";
@@ -202,7 +197,6 @@ function createSplitGroupStore(opts) {
202
197
  onPairChanged(group);
203
198
  return { ok: true, group: group };
204
199
  }
205
-
206
200
  function dissolveOwned(ownerId, id) {
207
201
  var index = groups.findIndex(function (item) { return item.id === id && (item.ownerId || null) === (ownerId || null); });
208
202
  if (index === -1) return false;
@@ -212,7 +206,157 @@ function createSplitGroupStore(opts) {
212
206
  onPairChanged(removed);
213
207
  return true;
214
208
  }
215
-
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
+ function beginOwnedDriverTransfer(ownerId, msg) {
213
+ var group = groups.find(function (item) { return item.id === (msg && msg.id); });
214
+ if (!group || (group.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Split group not found" };
215
+ if (!group.pair || group.pair.driverId !== msg.sourceDriverId || group.pair.workerId !== msg.workerId) {
216
+ return { ok: false, error: "The split pair changed before Driver transfer" };
217
+ }
218
+ var source = sessions.get(msg.sourceDriverId);
219
+ var target = sessions.get(msg.targetDriverId);
220
+ var worker = sessions.get(msg.workerId);
221
+ if (!source || !target || !worker || source === target || target === worker) return { ok: false, error: "Session not found" };
222
+ if ((source.ownerId || null) !== (ownerId || null) || (target.ownerId || null) !== (ownerId || null) ||
223
+ (worker.ownerId || null) !== (ownerId || null)) return { ok: false, error: "Split pair owner changed" };
224
+ if (membershipGroup(target.localId)) return { ok: false, error: "The successor already belongs to a split group" };
225
+ var sourceIndex = group.members.indexOf(source.localId);
226
+ if (sourceIndex === -1 || group.members.indexOf(worker.localId) === -1) return { ok: false, error: "Split group members changed" };
227
+ var before = {
228
+ members: group.members.slice(),
229
+ memberCliIds: Array.isArray(group.memberCliIds) ? group.memberCliIds.slice() : null,
230
+ memberOriginIds: Array.isArray(group.memberOriginIds) ? group.memberOriginIds.slice() : null,
231
+ pair: Object.assign({}, group.pair),
232
+ pairCliIds: Array.isArray(group.pairCliIds) ? group.pairCliIds.slice() : null,
233
+ pairOriginIds: Array.isArray(group.pairOriginIds) ? group.pairOriginIds.slice() : null,
234
+ };
235
+ var runtimeMembers = group.members.slice();
236
+ runtimeMembers[sourceIndex] = target.localId;
237
+ var runtimeGroup = Object.assign({}, group, { members: runtimeMembers,
238
+ pair: { driverId: target.localId, workerId: worker.localId } });
239
+ var transaction = { group: group, before: before, source: source, target: target, worker: worker,
240
+ runtimeGroup: runtimeGroup,
241
+ ownerId: ownerId || null, sourceOriginId: source.sessionOriginId || null,
242
+ targetOriginId: target.sessionOriginId || null, workerOriginId: worker.sessionOriginId || null,
243
+ workerCliSessionId: worker.cliSessionId || null, staged: true, committed: false };
244
+ stagedTransfers.push(transaction);
245
+ return { ok: true, group: runtimeGroup, transaction: transaction };
246
+ }
247
+ function removeStaged(transaction) {
248
+ var index = stagedTransfers.indexOf(transaction);
249
+ if (index !== -1) stagedTransfers.splice(index, 1);
250
+ }
251
+ function restoreTransfer(transaction) {
252
+ var group = transaction.group;
253
+ group.members = transaction.before.members.slice();
254
+ group.pair = Object.assign({}, transaction.before.pair);
255
+ if (transaction.before.memberCliIds) group.memberCliIds = transaction.before.memberCliIds.slice();
256
+ else delete group.memberCliIds;
257
+ if (transaction.before.memberOriginIds) group.memberOriginIds = transaction.before.memberOriginIds.slice();
258
+ else delete group.memberOriginIds;
259
+ if (transaction.before.pairCliIds) group.pairCliIds = transaction.before.pairCliIds.slice();
260
+ else delete group.pairCliIds;
261
+ if (transaction.before.pairOriginIds) group.pairOriginIds = transaction.before.pairOriginIds.slice();
262
+ else delete group.pairOriginIds;
263
+ }
264
+ function stagedTransfer(transaction) {
265
+ return transaction && transaction.staged && !transaction.committed &&
266
+ stagedTransfers.indexOf(transaction) !== -1 && groups.indexOf(transaction.group) !== -1 &&
267
+ transaction.runtimeGroup.pair && transaction.runtimeGroup.pair.driverId === transaction.target.localId &&
268
+ transaction.runtimeGroup.pair.workerId === transaction.worker.localId &&
269
+ transaction.runtimeGroup.members.length === 2 && transaction.runtimeGroup.members.indexOf(transaction.target.localId) !== -1 &&
270
+ transaction.runtimeGroup.members.indexOf(transaction.worker.localId) !== -1;
271
+ }
272
+ function exactTransfer(transaction) {
273
+ return stagedTransfer(transaction) &&
274
+ sessions.get(transaction.source.localId) === transaction.source &&
275
+ sessions.get(transaction.target.localId) === transaction.target &&
276
+ sessions.get(transaction.worker.localId) === transaction.worker &&
277
+ (transaction.group.ownerId || null) === transaction.ownerId &&
278
+ (transaction.source.ownerId || null) === transaction.ownerId &&
279
+ (transaction.target.ownerId || null) === transaction.ownerId &&
280
+ (transaction.worker.ownerId || null) === transaction.ownerId &&
281
+ (transaction.source.sessionOriginId || null) === transaction.sourceOriginId &&
282
+ (transaction.target.sessionOriginId || null) === transaction.targetOriginId &&
283
+ (transaction.worker.sessionOriginId || null) === transaction.workerOriginId &&
284
+ (transaction.worker.cliSessionId || null) === transaction.workerCliSessionId &&
285
+ transaction.group.pair && transaction.group.pair.driverId === transaction.source.localId &&
286
+ transaction.group.pair.workerId === transaction.worker.localId &&
287
+ transaction.group.members.length === 2 && transaction.group.members.indexOf(transaction.source.localId) !== -1 &&
288
+ transaction.group.members.indexOf(transaction.worker.localId) !== -1 &&
289
+ groupCountForMember(transaction.target.localId) === 0 && groupCountForMember(transaction.worker.localId) === 1 &&
290
+ groupCountForMember(transaction.source.localId) === 1;
291
+ }
292
+ function commitOwnedDriverTransfer(transaction) {
293
+ if (!exactTransfer(transaction)) {
294
+ removeStaged(transaction);
295
+ transaction.staged = false;
296
+ return { ok: false, error: "The staged Driver transfer is no longer exact" };
297
+ }
298
+ if (!transaction.target.cliSessionId ||
299
+ (!transaction.worker.cliSessionId && !transaction.worker.sessionOriginId)) {
300
+ return { ok: false, error: "The transferred pair does not have durable session anchors" };
301
+ }
302
+ transaction.group.members = transaction.runtimeGroup.members.slice();
303
+ transaction.group.pair = Object.assign({}, transaction.runtimeGroup.pair);
304
+ try { save(); }
305
+ catch (error) {
306
+ restoreTransfer(transaction);
307
+ removeStaged(transaction);
308
+ transaction.staged = false;
309
+ return { ok: false, error: error.message || String(error) };
310
+ }
311
+ removeStaged(transaction);
312
+ transaction.committed = true;
313
+ broadcast();
314
+ onPairChanged(transaction.group);
315
+ return { ok: true, group: transaction.group };
316
+ }
317
+ function rollbackOwnedDriverTransfer(transaction) {
318
+ if (stagedTransfer(transaction)) {
319
+ removeStaged(transaction);
320
+ transaction.staged = false;
321
+ return { ok: true, group: transaction.group };
322
+ }
323
+ if (!transaction || !transaction.committed || groups.indexOf(transaction.group) === -1 ||
324
+ transaction.group.pair.driverId !== transaction.target.localId ||
325
+ transaction.group.pair.workerId !== transaction.worker.localId) {
326
+ return { ok: false, error: "The staged Driver transfer is no longer exact" };
327
+ }
328
+ var wasCommitted = transaction.committed;
329
+ var committedState = {
330
+ members: transaction.group.members.slice(),
331
+ memberCliIds: Array.isArray(transaction.group.memberCliIds) ? transaction.group.memberCliIds.slice() : null,
332
+ memberOriginIds: Array.isArray(transaction.group.memberOriginIds) ? transaction.group.memberOriginIds.slice() : null,
333
+ pair: Object.assign({}, transaction.group.pair),
334
+ pairCliIds: Array.isArray(transaction.group.pairCliIds) ? transaction.group.pairCliIds.slice() : null,
335
+ pairOriginIds: Array.isArray(transaction.group.pairOriginIds) ? transaction.group.pairOriginIds.slice() : null,
336
+ };
337
+ restoreTransfer(transaction);
338
+ if (wasCommitted) {
339
+ try { save(); }
340
+ catch (error) {
341
+ transaction.group.members = committedState.members;
342
+ transaction.group.pair = committedState.pair;
343
+ if (committedState.memberCliIds) transaction.group.memberCliIds = committedState.memberCliIds;
344
+ else delete transaction.group.memberCliIds;
345
+ if (committedState.memberOriginIds) transaction.group.memberOriginIds = committedState.memberOriginIds;
346
+ else delete transaction.group.memberOriginIds;
347
+ if (committedState.pairCliIds) transaction.group.pairCliIds = committedState.pairCliIds;
348
+ else delete transaction.group.pairCliIds;
349
+ if (committedState.pairOriginIds) transaction.group.pairOriginIds = committedState.pairOriginIds;
350
+ else delete transaction.group.pairOriginIds;
351
+ return { ok: false, error: error.message || String(error) };
352
+ }
353
+ broadcast();
354
+ onPairChanged(transaction.group);
355
+ }
356
+ transaction.staged = false;
357
+ transaction.committed = false;
358
+ return { ok: true, group: transaction.group };
359
+ }
216
360
  function rename(ws, msg) {
217
361
  var group = groups.find(function (item) { return item.id === (msg && msg.id); });
218
362
  if (!group) return { ok: false, error: "Split group not found" };
@@ -237,6 +381,7 @@ function createSplitGroupStore(opts) {
237
381
  if (msg.driverId == null) {
238
382
  delete group.pair;
239
383
  delete group.pairCliIds;
384
+ delete group.pairOriginIds;
240
385
  save();
241
386
  broadcast();
242
387
  onPairChanged(group);
@@ -275,9 +420,11 @@ function createSplitGroupStore(opts) {
275
420
  function refreshAnchors(localId) {
276
421
  var group = groupForMember(localId);
277
422
  if (!group) return false;
278
- var fresh = currentCliIds(group);
279
- var prev = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
280
- if (fresh[0] === prev[0] && fresh[1] === prev[1]) return false;
423
+ var fresh = durableAnchors.memberAnchors(group, sessions);
424
+ var prevCli = Array.isArray(group.memberCliIds) ? group.memberCliIds : [null, null];
425
+ var prevOrigin = Array.isArray(group.memberOriginIds) ? group.memberOriginIds : [null, null];
426
+ if (fresh.cli[0] === prevCli[0] && fresh.cli[1] === prevCli[1] &&
427
+ fresh.origin[0] === prevOrigin[0] && fresh.origin[1] === prevOrigin[1]) return false;
281
428
  save();
282
429
  return true;
283
430
  }
@@ -299,7 +446,10 @@ function createSplitGroupStore(opts) {
299
446
  }
300
447
 
301
448
  load();
302
- return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned, rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
449
+ return { create: create, createOwned: createOwned, dissolveOwned: dissolveOwned,
450
+ beginOwnedDriverTransfer: beginOwnedDriverTransfer, commitOwnedDriverTransfer: commitOwnedDriverTransfer,
451
+ rollbackOwnedDriverTransfer: rollbackOwnedDriverTransfer,
452
+ rename: rename, setPair: setPair, dissolve: dissolve, dissolveBySession: dissolveBySession,
303
453
  refreshAutoName: refreshAutoName, refreshAnchors: refreshAnchors,
304
454
  listFor: listFor, groupForMember: groupForMember, get groups() { return groups; } };
305
455
  }
package/lib/sessions.js CHANGED
@@ -24,6 +24,7 @@ function createSessionManager(opts) {
24
24
  var sessionViewedListeners = [];
25
25
  var onSessionRenamed = opts.onSessionRenamed || function () {};
26
26
  var onSessionIdentityAssigned = opts.onSessionIdentityAssigned || function () {};
27
+ var sessionIdentityAssignedListeners = [];
27
28
  var onSessionHydrate = opts.onSessionHydrate || null;
28
29
 
29
30
  // --- Multi-session state ---
@@ -148,7 +149,7 @@ function createSessionManager(opts) {
148
149
  }
149
150
 
150
151
  function saveSessionFile(session) {
151
- if (!session.cliSessionId) return;
152
+ if (!session.cliSessionId) return false;
152
153
  try {
153
154
  var metaObj = {
154
155
  type: "meta",
@@ -194,6 +195,8 @@ function createSessionManager(opts) {
194
195
  if (session.spawn) metaObj.spawn = session.spawn;
195
196
  if (session.assignment) metaObj.assignment = session.assignment;
196
197
  if (session.handoff) metaObj.handoff = session.handoff;
198
+ if (session.driverContinuation) metaObj.driverContinuation = session.driverContinuation;
199
+ if (session.driverContinuationDecline) metaObj.driverContinuationDecline = session.driverContinuationDecline;
197
200
  if (session.debateState) metaObj.debateState = session.debateState;
198
201
  if (session.debateSetupMode) metaObj.debateSetupMode = true;
199
202
  if (session.homeDebatePlanning) metaObj.homeDebatePlanning = true;
@@ -219,8 +222,10 @@ function createSessionManager(opts) {
219
222
  try { fs.chmodSync(tmpPath, 0o600); } catch (chmodErr) {}
220
223
  }
221
224
  fs.renameSync(tmpPath, sfPath);
225
+ return true;
222
226
  } catch(e) {
223
227
  console.error("[session] Failed to save session file:", e.message);
228
+ return false;
224
229
  }
225
230
  }
226
231
 
@@ -327,6 +332,8 @@ function createSessionManager(opts) {
327
332
  if (m.spawn) session.spawn = m.spawn;
328
333
  if (m.assignment) session.assignment = m.assignment;
329
334
  if (m.handoff) session.handoff = m.handoff;
335
+ if (m.driverContinuation) session.driverContinuation = m.driverContinuation;
336
+ if (m.driverContinuationDecline) session.driverContinuationDecline = m.driverContinuationDecline;
330
337
  if (m.debateState) session.debateState = m.debateState;
331
338
  if (m.debateSetupMode) session.debateSetupMode = true;
332
339
  if (m.homeDebatePlanning) session.homeDebatePlanning = true;
@@ -1340,8 +1347,19 @@ function createSessionManager(opts) {
1340
1347
  addOnSessionViewed: function (fn) { if (typeof fn === "function") sessionViewedListeners.push(fn); },
1341
1348
  setOnSessionRenamed: function (fn) { onSessionRenamed = fn || function () {}; },
1342
1349
  setOnSessionIdentityAssigned: function (fn) { onSessionIdentityAssigned = fn || function () {}; },
1350
+ addOnSessionIdentityAssigned: function (fn) {
1351
+ if (typeof fn !== "function") return function () {};
1352
+ sessionIdentityAssignedListeners.push(fn);
1353
+ return function () {
1354
+ sessionIdentityAssignedListeners = sessionIdentityAssignedListeners.filter(function (listener) { return listener !== fn; });
1355
+ };
1356
+ },
1343
1357
  notifySessionRenamed: function (localId) { onSessionRenamed(localId); },
1344
- notifySessionIdentityAssigned: function (localId) { onSessionIdentityAssigned(localId); },
1358
+ notifySessionIdentityAssigned: function (localId) {
1359
+ onSessionIdentityAssigned(localId);
1360
+ var listeners = sessionIdentityAssignedListeners.slice();
1361
+ for (var i = 0; i < listeners.length; i++) listeners[i](localId);
1362
+ },
1345
1363
  HISTORY_PAGE_SIZE: HISTORY_PAGE_SIZE,
1346
1364
  getActiveSession: getActiveSession,
1347
1365
  createSession: createSession,
package/lib/ws-schema.js CHANGED
@@ -44,6 +44,8 @@ var schema = {
44
44
  "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
45
  "pair_session_create": { direction: "c2s", handler: "lib/project-session-pair.js", description: "Create a Driver/Split Worker session pair as a split group" },
46
46
  "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" },
47
+ "driver_continuation_response": { direction: "c2s", handler: "lib/project-driver-continuation.js", description: "Accept or decline an exact source-bound Driver continuation proposal" },
48
+ "driver_continuation_open_source": { direction: "c2s", handler: "lib/project-driver-continuation.js", description: "Resolve a successor's stable origin link to its authorized original Driver" },
47
49
  "project_assignment_response": { direction: "c2s", handler: "lib/workspace-assignment-service.js", description: "Approve or cancel an exact-session cross-project assignment proposal" },
48
50
  "home_clay_session_resolve": { direction: "c2s", handler: "lib/server-home-chat.js", description: "Resolve an opaque Mate session reference through the exact bound source session" },
49
51
  "home_clay_session_target": { direction: "s2c", handler: "lib/public/modules/command-palette.js", description: "Return an owner-validated navigation target for a rendered Mate session reference" },
@@ -101,6 +103,10 @@ var schema = {
101
103
  "pair_session_created": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Result of pair_session_create; carries the new group" },
102
104
  "worker_proposal": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Recorded Split Worker creation/replacement runtime card with Driver rationale" },
103
105
  "worker_proposal_update": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Worker card lifecycle and manual/auto decision audit update" },
106
+ "driver_continuation_proposal": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Recorded review card for a compact Driver continuation" },
107
+ "driver_continuation_update": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Durable exact-proposal continuation lifecycle update" },
108
+ "driver_continuation_result": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Correlated response to a continuation decision" },
109
+ "driver_continuation_context": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Compact inherited context and original-session link in a successor" },
104
110
  "project_assignment_proposal": { direction: "s2c", handler: "lib/public/modules/app-message-router.js", description: "Approval card for a new owner-bound project assignment" },
105
111
  "project_assignment_status": { direction: "s2c", handler: "lib/public/modules/app-message-router.js", description: "Durable project assignment lifecycle update" },
106
112
  "project_assignment_error": { direction: "s2c", handler: "lib/public/modules/app-message-router.js", description: "Rejected or stale project assignment response" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.2.0",
3
+ "version": "4.3.0-beta.1",
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"