clay-server 4.3.0-beta.4 → 4.3.0-beta.6

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 (54) 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 +2 -0
  4. package/lib/project-pair-autonomous-stop.js +14 -8
  5. package/lib/project-pair-close-transaction.js +59 -0
  6. package/lib/project-pair-global-stop.js +18 -0
  7. package/lib/project-pair-lifecycle-status.js +34 -0
  8. package/lib/project-pair-lifecycle.js +58 -76
  9. package/lib/project-pair-message.js +1 -1
  10. package/lib/project-pair-owned-stop.js +7 -4
  11. package/lib/project-pair-result-capture.js +116 -0
  12. package/lib/project-pair-result-delivery.js +140 -0
  13. package/lib/project-pair-result-outbox.js +400 -0
  14. package/lib/project-pair-result-pipeline.js +16 -0
  15. package/lib/project-pair-result-recovery.js +229 -0
  16. package/lib/project-pair-structural-close.js +20 -0
  17. package/lib/project-pair-task-control.js +45 -18
  18. package/lib/project-pair-worker-close.js +68 -0
  19. package/lib/project-session-pair-prompt.js +26 -0
  20. package/lib/project-session-pair.js +87 -106
  21. package/lib/project-worker-creation-proposal.js +176 -0
  22. package/lib/project-worker-permission.js +7 -8
  23. package/lib/project-worker-proposal-runner.js +45 -0
  24. package/lib/project-worker-proposal.js +79 -115
  25. package/lib/project.js +38 -1
  26. package/lib/public/app.js +1 -0
  27. package/lib/public/css/messages.css +12 -0
  28. package/lib/public/css/pane.css +48 -2
  29. package/lib/public/modules/app-connection.js +49 -76
  30. package/lib/public/modules/app-messages.js +11 -0
  31. package/lib/public/modules/pair-result-status.js +87 -0
  32. package/lib/public/modules/project-activation.js +10 -7
  33. package/lib/public/modules/sidebar-sessions.js +31 -11
  34. package/lib/public/modules/split-group-helpers.js +74 -2
  35. package/lib/public/modules/split-pair-ui.js +28 -6
  36. package/lib/public/modules/split-pane-reconciler.js +28 -0
  37. package/lib/public/modules/split-pane-renderer.js +107 -0
  38. package/lib/public/modules/split-view.js +58 -188
  39. package/lib/public/modules/websocket-watchdog.js +126 -0
  40. package/lib/public/modules/worker-pane-lock.js +13 -3
  41. package/lib/server.js +1 -0
  42. package/lib/session-pair-factory.js +66 -0
  43. package/lib/session-pair-mcp-server.js +10 -5
  44. package/lib/session-pair-prompts.js +41 -25
  45. package/lib/session-pair-target.js +42 -0
  46. package/lib/session-pair-turn-control.js +11 -6
  47. package/lib/session-split-group-anchors.js +89 -7
  48. package/lib/session-split-group-persistence.js +136 -0
  49. package/lib/session-split-group-roles.js +164 -0
  50. package/lib/session-split-group-v2-store.js +146 -0
  51. package/lib/session-split-groups.js +109 -99
  52. package/lib/sessions.js +33 -2
  53. package/lib/ws-schema.js +2 -0
  54. package/package.json +1 -1
@@ -23,6 +23,7 @@ import { clearProjectSplitState } from './split-session-boundary.js';
23
23
  import { clearPermissionModePending } from './permission-control.js';
24
24
  import { clearMcpPermissionModePending } from './mcp-ui.js';
25
25
  import { createWebSocketLifecycle } from './websocket-lifecycle.js';
26
+ import { createWebSocketWatchdog } from './websocket-watchdog.js';
26
27
 
27
28
  var connectOverlay = null;
28
29
  var hasConnectedOnce = false;
@@ -42,16 +43,9 @@ var overlayGraceTimer = null;
42
43
  // server-global-ws.js. Well under the common 60s proxy read timeout, and one
43
44
  // timer per live socket.
44
45
  var HEARTBEAT_MS = 25000;
45
- var heartbeatTimer = null;
46
- var heartbeatDeadlineTimer = null;
47
- var heartbeatAwaitingSocket = null;
48
46
  var disconnectedAt = 0;
49
47
  var attemptStartedAt = 0;
50
48
  var attemptSocket = null;
51
- var probeTimer = null;
52
- var probeSocket = null;
53
- var probeEpoch = 0;
54
- var probeStartedAt = 0;
55
49
  var lifecycle = createWebSocketLifecycle({
56
50
  handshakeTimeoutMs: 12000,
57
51
  onHandshakeTimeout: function (epoch) {
@@ -60,6 +54,21 @@ var lifecycle = createWebSocketLifecycle({
60
54
  }
61
55
  }
62
56
  });
57
+ var watchdog = createWebSocketWatchdog({
58
+ heartbeatIntervalMs: HEARTBEAT_MS,
59
+ heartbeatDeadlineMs: 10000,
60
+ probeTimeoutMs: 4000,
61
+ now: function () { return Date.now(); },
62
+ isCurrent: function (socket, epoch) {
63
+ return getWs() === socket && socket.readyState === 1 && lifecycle.current(epoch);
64
+ },
65
+ sendPing: function (socket) {
66
+ socket.send(JSON.stringify({ type: "ping" }));
67
+ },
68
+ onFailure: function (reason, epoch, latency) {
69
+ forceReplaceSocket(reason, 0, epoch, latency);
70
+ }
71
+ });
63
72
 
64
73
  function clearOverlayGrace() {
65
74
  if (overlayGraceTimer) {
@@ -88,19 +97,10 @@ function recordConnectionDiagnostic(reason, code, latency) {
88
97
  });
89
98
  }
90
99
 
91
- function clearProbe() {
92
- if (probeTimer) clearTimeout(probeTimer);
93
- probeTimer = null;
94
- probeSocket = null;
95
- probeEpoch = 0;
96
- probeStartedAt = 0;
97
- }
98
-
99
- function forceReplaceSocket(reason, code, epoch) {
100
+ function forceReplaceSocket(reason, code, epoch, latency) {
100
101
  if (!lifecycle.current(epoch)) return;
101
102
  var socket = getWs();
102
- var latency = probeStartedAt ? Date.now() - probeStartedAt : null;
103
- clearProbe();
103
+ watchdog.clearProbe();
104
104
  stopHeartbeat();
105
105
  if (socket) {
106
106
  socket.onopen = null;
@@ -117,19 +117,11 @@ function forceReplaceSocket(reason, code, epoch) {
117
117
  }
118
118
 
119
119
  export function stopHeartbeat() {
120
- if (heartbeatTimer) {
121
- clearInterval(heartbeatTimer);
122
- heartbeatTimer = null;
123
- }
124
- if (heartbeatDeadlineTimer) {
125
- clearTimeout(heartbeatDeadlineTimer);
126
- heartbeatDeadlineTimer = null;
127
- }
128
- heartbeatAwaitingSocket = null;
120
+ watchdog.stopHeartbeat();
129
121
  }
130
122
 
131
123
  function suspendSocketForOffline() {
132
- clearProbe();
124
+ watchdog.clearProbe();
133
125
  stopHeartbeat();
134
126
  var socket = getWs();
135
127
  attemptSocket = null;
@@ -150,31 +142,26 @@ function suspendSocketForOffline() {
150
142
  // timeout from expiring. A missing pong also identifies a half-open socket,
151
143
  // which browsers can otherwise leave OPEN while silently losing sends.
152
144
  export function startHeartbeat(socket, attemptEpoch) {
153
- stopHeartbeat();
154
- if (!socket) return;
155
- heartbeatTimer = setInterval(function () {
156
- if (!socket || socket.readyState !== 1 || getWs() !== socket || !lifecycle.current(attemptEpoch)) {
157
- stopHeartbeat();
158
- return;
159
- }
160
- try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {
161
- forceReplaceSocket("heartbeat_error", 0, attemptEpoch);
162
- return;
163
- }
164
- heartbeatAwaitingSocket = socket;
165
- if (heartbeatDeadlineTimer) clearTimeout(heartbeatDeadlineTimer);
166
- heartbeatDeadlineTimer = setTimeout(function () {
167
- heartbeatDeadlineTimer = null;
168
- if (getWs() === socket && socket.readyState === 1 && lifecycle.current(attemptEpoch)) {
169
- forceReplaceSocket("heartbeat_timeout", 0, attemptEpoch);
170
- }
171
- }, 10000);
172
- }, HEARTBEAT_MS);
145
+ watchdog.startHeartbeat(socket, attemptEpoch);
146
+ }
147
+
148
+ function suspendSocketWatchdogs() {
149
+ // A frozen page cannot promise proxy keepalive. Retire its deadlines without
150
+ // closing the socket; resume performs a fresh bounded liveness check.
151
+ watchdog.suspend();
152
+ }
153
+
154
+ function resumeSocketWatchdogs(reason) {
155
+ var resumed = watchdog.resume();
156
+ var socket = getWs();
157
+ if (socket && socket.readyState === 1 && resumed) startHeartbeat(socket, lifecycle.getEpoch());
158
+ probeReconnect(reason);
173
159
  }
174
160
 
175
161
  export function initConnection() {
176
162
  connectOverlay = document.getElementById("connect-overlay");
177
163
  if (typeof navigator !== "undefined" && navigator.onLine === false) lifecycle.setOffline(true);
164
+ if (document.hidden) suspendSocketWatchdogs();
178
165
  window.addEventListener("clay-message-delivery-timeout", function (event) {
179
166
  var socket = event.detail && event.detail.socket;
180
167
  recordConnectionDiagnostic("ack-timeout", 0, null);
@@ -186,17 +173,19 @@ export function initConnection() {
186
173
  });
187
174
  window.addEventListener("online", function () {
188
175
  lifecycle.wake(function () {
189
- var socket = getWs();
190
- if (socket && socket.readyState === 1) startHeartbeat(socket, lifecycle.getEpoch());
191
- probeReconnect("online");
176
+ if (document.hidden) return;
177
+ resumeSocketWatchdogs("online");
192
178
  });
193
179
  });
194
180
  document.addEventListener("visibilitychange", function () {
195
- if (!document.hidden) {
196
- var socket = getWs();
197
- if (socket && socket.readyState === 1) startHeartbeat(socket, lifecycle.getEpoch());
198
- probeReconnect("visible");
199
- }
181
+ if (document.hidden) suspendSocketWatchdogs();
182
+ else resumeSocketWatchdogs("visible");
183
+ });
184
+ document.addEventListener("freeze", function () {
185
+ suspendSocketWatchdogs();
186
+ });
187
+ document.addEventListener("resume", function () {
188
+ if (!document.hidden) resumeSocketWatchdogs("visible");
200
189
  });
201
190
 
202
191
  // --- Reactive UI sync for connected/processing state ---
@@ -379,7 +368,7 @@ export function connect() {
379
368
  newWs.onclose = function (e) {
380
369
  if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
381
370
  stopHeartbeat();
382
- clearProbe();
371
+ watchdog.clearProbe();
383
372
  disconnectedAt = Date.now();
384
373
  recordConnectionDiagnostic("close", e && e.code, attemptStartedAt ? Date.now() - attemptStartedAt : null);
385
374
  closeDmUserPicker();
@@ -408,18 +397,13 @@ export function connect() {
408
397
  var msg;
409
398
  try { msg = JSON.parse(event.data); } catch (e) { return; }
410
399
  lifecycle.markLive(attemptEpoch);
411
- if (msg.type === "pong" && heartbeatDeadlineTimer && heartbeatAwaitingSocket === newWs) {
412
- clearTimeout(heartbeatDeadlineTimer);
413
- heartbeatDeadlineTimer = null;
414
- heartbeatAwaitingSocket = null;
415
- }
416
- if (msg.type === "pong" && probeSocket === newWs && lifecycle.current(probeEpoch)) clearProbe();
400
+ if (msg.type === "pong") watchdog.acceptPong(newWs, attemptEpoch);
417
401
  processMessage(msg);
418
402
  };
419
403
  }
420
404
 
421
405
  export function cancelReconnect() {
422
- clearProbe();
406
+ watchdog.clearProbe();
423
407
  stopHeartbeat();
424
408
  lifecycle.cancel();
425
409
  }
@@ -429,18 +413,7 @@ function probeReconnect(reason) {
429
413
  var socket = getWs();
430
414
  if (socket && socket.readyState === 0) return;
431
415
  if (socket && socket.readyState === 1) {
432
- if (probeTimer) return;
433
- probeSocket = socket;
434
- probeEpoch = lifecycle.getEpoch();
435
- probeStartedAt = Date.now();
436
- try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {
437
- forceReplaceSocket("probe_error", 0, probeEpoch);
438
- return;
439
- }
440
- probeTimer = setTimeout(function () {
441
- probeTimer = null;
442
- if (probeSocket === socket && lifecycle.current(probeEpoch) && getWs() === socket && socket.readyState === 1) forceReplaceSocket("probe_timeout", 0, probeEpoch);
443
- }, 4000);
416
+ watchdog.probe(socket, lifecycle.getEpoch());
444
417
  return;
445
418
  }
446
419
  scheduleReconnect(reason);
@@ -62,6 +62,7 @@ import { handleShellCommandResult } from './shell-command.js';
62
62
  import { handleLoopRegistryUpdated, handleScheduleRunStarted, handleScheduleRunFinished, handleLoopScheduled, isSchedulerOpen, enterCraftingMode, exitCraftingMode, handleLoopRegistryFiles } from './scheduler.js';
63
63
  import { handleScheduledTaskMessage, handleScheduledTaskSessionSwitched } from './scheduled-tasks.js';
64
64
  import { renderGeneratedImage, renderImageGenerationProgress, clearImageGenerationProgress, clearAllImageGenerationProgress } from './generated-images.js';
65
+ import { clearPairResultStatus, handlePairResultStatus } from './pair-result-status.js';
65
66
 
66
67
  // --- App module imports ---
67
68
  import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
@@ -243,6 +244,7 @@ export function processMessage(msg) {
243
244
  // Restore cached rich context usage BEFORE updateContextPanel runs
244
245
  if (msg.contextUsage && !msg.contextUsage.unavailable) store.set({ richContextUsage: msg.contextUsage });
245
246
  else markContextUnavailable();
247
+
246
248
  // Restore accurate context data from the last result in full history
247
249
  if (msg.lastUsage || msg.lastModelUsage) {
248
250
  accumulateContext(msg.lastCost, msg.lastUsage, msg.lastModelUsage, msg.lastStreamInputTokens);
@@ -735,6 +737,10 @@ export function processMessage(msg) {
735
737
  if (!msg.ok && msg.error) showToast(msg.error, "error");
736
738
  break;
737
739
 
740
+ case "split_worker_close_result":
741
+ if (!msg.ok && msg.error) showToast(msg.error, "error");
742
+ break;
743
+
738
744
  case "pair_session_options":
739
745
  showPairDialog(msg);
740
746
  break;
@@ -804,6 +810,10 @@ export function processMessage(msg) {
804
810
  handleRemoteSelection(msg);
805
811
  break;
806
812
 
813
+ case "pair_result_status":
814
+ handlePairResultStatus(msg);
815
+ break;
816
+
807
817
  case "session_io":
808
818
  blinkSessionDot(msg.id);
809
819
  break;
@@ -823,6 +833,7 @@ export function processMessage(msg) {
823
833
  break;
824
834
 
825
835
  case "session_switched":
836
+ clearPairResultStatus();
826
837
  syncAutonomousRunForSession(msg.autonomousRun || null);
827
838
  closeWhatsNewArticle();
828
839
  // Save draft from outgoing session
@@ -0,0 +1,87 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { showConfirm } from './app-misc.js';
4
+
5
+ var statusGeneration = 0;
6
+
7
+ function retrySource(item) {
8
+ return { item: item, generation: statusGeneration, projectSlug: store.get("currentSlug") || store.get("activeProjectSlug") || null,
9
+ sessionId: store.get("activeSessionId"), userId: store.get("myUserId") || null, ws: getWs() };
10
+ }
11
+
12
+ function sendRetry(source, confirmed) {
13
+ var ws = getWs();
14
+ var projectSlug = store.get("currentSlug") || store.get("activeProjectSlug") || null;
15
+ var items = store.get("pairResultStatus") || [];
16
+ var stillVisible = items.some(function (item) { return item.id === source.item.id; });
17
+ if (!ws || ws !== source.ws || ws.readyState !== 1 || statusGeneration !== source.generation ||
18
+ store.get("activeSessionId") !== source.sessionId || projectSlug !== source.projectSlug ||
19
+ (store.get("myUserId") || null) !== source.userId || !stillVisible) return false;
20
+ ws.send(JSON.stringify({ type: "pair_result_retry", id: source.item.id, confirmDuplicate: confirmed === true,
21
+ sessionId: source.sessionId, projectSlug: source.projectSlug }));
22
+ return true;
23
+ }
24
+
25
+ function retry(item) {
26
+ var source = retrySource(item);
27
+ if (item.confirmDuplicate) {
28
+ showConfirm("The Driver may already have received this completed result. Retry delivery anyway? This can cause a duplicate result receipt.", function () {
29
+ sendRetry(source, true);
30
+ }, "Retry delivery", false);
31
+ return;
32
+ }
33
+ sendRetry(source, false);
34
+ }
35
+
36
+ function renderPairResultStatus() {
37
+ var existing = document.getElementById("pair-result-status");
38
+ if (existing) existing.remove();
39
+ var items = store.get("pairResultStatus") || [];
40
+ if (!items.length) return;
41
+ var messages = document.getElementById("messages");
42
+ if (!messages || !messages.parentElement) return;
43
+ var section = document.createElement("section");
44
+ section.id = "pair-result-status";
45
+ section.className = "pair-result-status";
46
+ section.setAttribute("role", "status");
47
+ for (var i = 0; i < items.length; i++) {
48
+ var item = items[i], card = document.createElement("div");
49
+ card.className = "pair-result-status-card pair-result-status-" + item.state;
50
+ var title = document.createElement("strong");
51
+ title.textContent = item.state === "blocked" ? "Split Worker result blocked" :
52
+ (item.state === "uncertain" ? (item.hasCompletedResult === false ? "Split Worker recovery uncertain" : "Split Worker result delivery uncertain") : "Split Worker result pending");
53
+ var message = document.createElement("span");
54
+ message.textContent = item.message || "The completed result is retained.";
55
+ card.appendChild(title); card.appendChild(message);
56
+ if (item.hasCompletedResult !== false) {
57
+ var details = document.createElement("details"), summary = document.createElement("summary"), body = document.createElement("pre");
58
+ summary.textContent = "View completed result";
59
+ body.textContent = item.error ? "Error: " + item.error : (item.response || "No text response was recorded.");
60
+ details.appendChild(summary); details.appendChild(body); card.appendChild(details);
61
+ }
62
+ if (item.canRetry) {
63
+ var button = document.createElement("button");
64
+ button.type = "button"; button.className = "confirm-btn confirm-ok pair-result-status-retry"; button.textContent = "Retry delivery";
65
+ button.addEventListener("click", retry.bind(null, item));
66
+ card.appendChild(button);
67
+ }
68
+ section.appendChild(card);
69
+ }
70
+ messages.parentElement.insertBefore(section, messages);
71
+ }
72
+
73
+ function handlePairResultStatus(msg) {
74
+ if (!msg || msg.sessionId !== store.get("activeSessionId")) return false;
75
+ statusGeneration++;
76
+ store.set({ pairResultStatus: Array.isArray(msg.items) ? msg.items : [] });
77
+ renderPairResultStatus();
78
+ return true;
79
+ }
80
+
81
+ function clearPairResultStatus() {
82
+ statusGeneration++;
83
+ store.set({ pairResultStatus: [] });
84
+ renderPairResultStatus();
85
+ }
86
+
87
+ export { clearPairResultStatus, handlePairResultStatus, renderPairResultStatus };
@@ -59,19 +59,22 @@ export function isProjectSessionReady(state, socket) {
59
59
  }
60
60
 
61
61
  export function splitPanesMatchProject(split, slug) {
62
- if (!split || !Array.isArray(split.panes) || split.panes.length !== 2 || !slug) return false;
63
- return split.panes[0].slug === slug && split.panes[1].slug === slug;
62
+ if (!split || !Array.isArray(split.panes) || split.panes.length < 2 || split.panes.length > 3 || !slug) return false;
63
+ for (var i = 0; i < split.panes.length; i++) if (split.panes[i].slug !== slug) return false;
64
+ return true;
64
65
  }
65
66
 
66
67
  export function hasCurrentSplitGroup(split, groups) {
67
68
  if (!split || !split.groupId) return true;
68
69
  var members = split.panes && split.panes.map(function (pane) { return pane.sessionId; });
69
- if (!members || members.length !== 2) return false;
70
+ if (!members || members.length < 2 || members.length > 3) return false;
70
71
  var available = Array.isArray(groups) ? groups : [];
71
72
  for (var i = 0; i < available.length; i++) {
72
73
  var group = available[i];
73
- if (group.id !== split.groupId || !Array.isArray(group.members) || group.members.length !== 2) continue;
74
- if (group.members.indexOf(members[0]) !== -1 && group.members.indexOf(members[1]) !== -1) return true;
74
+ if (group.id !== split.groupId || !Array.isArray(group.members) || group.members.length !== members.length) continue;
75
+ var matches = true;
76
+ for (var mi = 0; mi < members.length; mi++) if (group.members.indexOf(members[mi]) === -1) matches = false;
77
+ if (matches) return true;
75
78
  }
76
79
  return false;
77
80
  }
@@ -84,8 +87,8 @@ export function reconcileRestoredSplit(split, groups) {
84
87
  if (group.id !== split.groupId) continue;
85
88
  var oldMembers = split.panes && split.panes.map(function (pane) { return pane.sessionId; });
86
89
  var members = Array.isArray(group.members) ? group.members : [];
87
- if (oldMembers && oldMembers.length === 2 && members.length === 2
88
- && members.indexOf(oldMembers[0]) !== -1 && members.indexOf(oldMembers[1]) !== -1) {
90
+ if (oldMembers && oldMembers.length === members.length
91
+ && oldMembers.every(function (id) { return members.indexOf(id) !== -1; })) {
89
92
  return { action: "keep", group: group };
90
93
  }
91
94
  return { action: "rebuild", group: group };
@@ -1429,14 +1429,20 @@ function renderSplitGroupItem(group, members) {
1429
1429
  escapeHtml(VENDOR_NAMES[vendor] || vendor) + '">';
1430
1430
  }
1431
1431
  html += "</span>";
1432
- if (members[0].isProcessing || members[1].isProcessing) html += '<span class="session-processing"></span>';
1433
- var splitBackgroundTaskCount = (members[0].backgroundTaskCount || 0) + (members[1].backgroundTaskCount || 0);
1432
+ var splitProcessing = false;
1433
+ var splitBackgroundTaskCount = 0;
1434
+ for (var si = 0; si < members.length; si++) {
1435
+ if (members[si].isProcessing) splitProcessing = true;
1436
+ splitBackgroundTaskCount += members[si].backgroundTaskCount || 0;
1437
+ }
1438
+ if (splitProcessing) html += '<span class="session-processing"></span>';
1434
1439
  if (splitBackgroundTaskCount) html += '<span class="session-background-task-count">' + splitBackgroundTaskCount + '</span>';
1435
1440
  html += '<span class="split-group-name">' + highlightMatch(group.name || "Split", searchQuery) + "</span>";
1436
1441
  text.innerHTML = html;
1437
1442
  el.appendChild(text);
1438
1443
 
1439
- var unread = (members[0].unread || 0) + (members[1].unread || 0);
1444
+ var unread = 0;
1445
+ for (var ui = 0; ui < members.length; ui++) unread += members[ui].unread || 0;
1440
1446
  var badge = document.createElement("span");
1441
1447
  badge.className = "session-unread-badge" + (unread > 0 ? " has-unread" : "");
1442
1448
  badge.dataset.splitGroupId = group.id;
@@ -1532,15 +1538,24 @@ export function renderSessionList(sessions) {
1532
1538
  }
1533
1539
  for (var sg = 0; sg < splitGroups.length; sg++) {
1534
1540
  var splitGroup = splitGroups[sg];
1535
- var leftMember = sessionsById.get(splitGroup.members[0]);
1536
- var rightMember = sessionsById.get(splitGroup.members[1]);
1537
- if (!leftMember || !rightMember) continue;
1538
- if (hierarchySessionIds.has(leftMember.id) || hierarchySessionIds.has(rightMember.id)) continue;
1541
+ var splitMembers = [];
1542
+ for (var smi = 0; smi < splitGroup.members.length; smi++) {
1543
+ var splitMember = sessionsById.get(splitGroup.members[smi]);
1544
+ if (splitMember) splitMembers.push(splitMember);
1545
+ }
1546
+ if (splitMembers.length !== splitGroup.members.length) continue;
1547
+ var hasHierarchyMember = false;
1548
+ for (var hmi = 0; hmi < splitMembers.length; hmi++) {
1549
+ if (hierarchySessionIds.has(splitMembers[hmi].id)) hasHierarchyMember = true;
1550
+ }
1551
+ if (hasHierarchyMember) continue;
1552
+ var splitLastActivity = 0;
1553
+ for (var lai = 0; lai < splitMembers.length; lai++) splitLastActivity = Math.max(splitLastActivity, splitMembers[lai].lastActivity || 0);
1539
1554
  items.push({
1540
1555
  type: "split-group",
1541
1556
  group: splitGroup,
1542
- members: [leftMember, rightMember],
1543
- lastActivity: Math.max(leftMember.lastActivity || 0, rightMember.lastActivity || 0),
1557
+ members: splitMembers,
1558
+ lastActivity: splitLastActivity,
1544
1559
  });
1545
1560
  }
1546
1561
  var groupKeys = Object.keys(loopGroups);
@@ -1567,8 +1582,13 @@ export function renderSessionList(sessions) {
1567
1582
  continue;
1568
1583
  }
1569
1584
  if ((item.type === "driver-hierarchy" || item.type === "orphan-workers") && !hierarchyItemMatches(item, searchMatchIds)) continue;
1570
- if (item.type === "split-group" && searchMatchIds !== null &&
1571
- !searchMatchIds.has(item.members[0].id) && !searchMatchIds.has(item.members[1].id)) continue;
1585
+ if (item.type === "split-group" && searchMatchIds !== null) {
1586
+ var splitSearchMatch = false;
1587
+ for (var splitSearchIndex = 0; splitSearchIndex < item.members.length; splitSearchIndex++) {
1588
+ if (searchMatchIds.has(item.members[splitSearchIndex].id)) splitSearchMatch = true;
1589
+ }
1590
+ if (!splitSearchMatch) continue;
1591
+ }
1572
1592
  if ((item.type === "session" || item.type === "driver-hierarchy") && item.data && item.data.bookmarked) {
1573
1593
  bookmarkedItems.push(item);
1574
1594
  } else {
@@ -12,15 +12,87 @@ export function findSplitGroup(groups, memberIds) {
12
12
  var list = groups || [];
13
13
  for (var i = 0; i < list.length; i++) {
14
14
  var members = list[i].members || [];
15
- if (members.length === 2 && members[0] === memberIds[0] && members[1] === memberIds[1]) return list[i];
15
+ if (members.length !== memberIds.length) continue;
16
+ var matches = true;
17
+ for (var j = 0; j < memberIds.length; j++) {
18
+ if (members.indexOf(memberIds[j]) === -1) { matches = false; break; }
19
+ }
20
+ if (matches) return list[i];
16
21
  }
17
22
  return null;
18
23
  }
19
24
 
25
+ // Resolve the visible order from the explicit role contract. Member array
26
+ // order is persistence detail and must never decide who is the Driver.
27
+ export function splitGroupRoles(group) {
28
+ if (!group || !Array.isArray(group.members)) return null;
29
+ var pair = group.pair;
30
+ if (!pair || typeof pair !== "object" || Array.isArray(pair)) return null;
31
+ var hasVersion = Object.prototype.hasOwnProperty.call(pair, "version");
32
+ var hasLegacyWorker = Object.prototype.hasOwnProperty.call(pair, "workerId");
33
+ var hasWorkerIds = Object.prototype.hasOwnProperty.call(pair, "workerIds");
34
+ if (hasVersion && pair.version !== 2) return null;
35
+ if (hasVersion && hasLegacyWorker) return null;
36
+ if (hasVersion && !hasWorkerIds) return null;
37
+ if (!hasVersion && hasWorkerIds) return null;
38
+ if (!hasVersion && !hasLegacyWorker) return null;
39
+ var driverId = pair.driverId;
40
+ var workerIds = hasWorkerIds ? (Array.isArray(pair.workerIds) ? pair.workerIds.slice() : []) :
41
+ (pair.workerId !== undefined ? [pair.workerId] : []);
42
+ if (!Number.isInteger(driverId) || workerIds.length < 1 || workerIds.length > 2) return null;
43
+ if (group.members.indexOf(driverId) === -1) return null;
44
+ var ordered = [driverId];
45
+ for (var i = 0; i < workerIds.length; i++) {
46
+ if (!Number.isInteger(workerIds[i]) || workerIds[i] === driverId || group.members.indexOf(workerIds[i]) === -1 || ordered.indexOf(workerIds[i]) !== -1) return null;
47
+ ordered.push(workerIds[i]);
48
+ }
49
+ if (group.members.length !== ordered.length) return null;
50
+ for (var mi = 0; mi < group.members.length; mi++) if (ordered.indexOf(group.members[mi]) === -1) return null;
51
+ return { driverId: driverId, workerIds: workerIds, orderedIds: ordered, version: pair.version || null };
52
+ }
53
+
54
+ export function splitGroupMemberIds(group) {
55
+ var roles = splitGroupRoles(group);
56
+ return roles ? roles.orderedIds : (group && Array.isArray(group.members) ? group.members.slice() : []);
57
+ }
58
+
59
+ export function splitGroupActiveAnchor(group, activeId) {
60
+ var roles = splitGroupRoles(group);
61
+ if (roles) return roles.driverId;
62
+ var members = group && Array.isArray(group.members) ? group.members : [];
63
+ return members.indexOf(activeId) !== -1 ? activeId : (members[0] || null);
64
+ }
65
+
20
66
  export function isConfiguredWorker(groups, sessionId) {
21
67
  var list = groups || [];
22
68
  for (var i = 0; i < list.length; i++) {
23
- if (list[i].pair && list[i].pair.workerId === sessionId) return true;
69
+ var roles = splitGroupRoles(list[i]);
70
+ if (roles && roles.workerIds.indexOf(sessionId) !== -1) return true;
24
71
  }
25
72
  return false;
26
73
  }
74
+
75
+ export function splitWorkerCloseRequest(group, sessions, projectSlug, workerId, requestId) {
76
+ var roles = splitGroupRoles(group);
77
+ if (!roles || roles.version !== 2 || roles.workerIds.indexOf(workerId) === -1) return null;
78
+ var anchors = group && group.pairAnchors;
79
+ if (!anchors || anchors.version !== 2 || !anchors.driver || !Array.isArray(anchors.workers)) return null;
80
+ var workerIndex = roles.workerIds.indexOf(workerId);
81
+ var workerAnchor = anchors.workers[workerIndex];
82
+ var worker = null;
83
+ var list = sessions || [];
84
+ for (var i = 0; i < list.length; i++) if (list[i].id === workerId) worker = list[i];
85
+ if (!workerAnchor || !anchors.driver.origin || !workerAnchor.origin || !worker || !Number.isInteger(worker.workerGeneration)) return null;
86
+ return {
87
+ type: "split_worker_close",
88
+ requestId: requestId,
89
+ projectSlug: projectSlug,
90
+ groupId: group.id,
91
+ driverId: roles.driverId,
92
+ driverOriginId: anchors.driver.origin,
93
+ workerId: workerId,
94
+ workerOriginId: workerAnchor.origin,
95
+ generation: worker.workerGeneration,
96
+ expectedWorkerIds: roles.workerIds.slice(),
97
+ };
98
+ }
@@ -5,6 +5,7 @@ import { buildAgentVendorSelect, fillAgentModels, buildAgentEffortSelect, fillAg
5
5
  import { showToast } from './utils.js';
6
6
  import { getCachedSessions } from './sidebar-sessions.js';
7
7
  import { appendWorkerRuntime } from './split-worker-runtime.js';
8
+ import { splitGroupRoles } from './split-group-helpers.js';
8
9
 
9
10
  var pairDialog = null;
10
11
  // Set when the dialog was opened via "Add Split Worker" on an existing session:
@@ -245,6 +246,8 @@ export function syncPairChrome(host, split) {
245
246
  var paneEls = host.querySelectorAll(".split-pane");
246
247
  for (var paneIndex = 0; paneIndex < paneEls.length; paneIndex++) {
247
248
  paneEls[paneIndex].classList.remove("split-pane-role-driver", "split-pane-role-worker");
249
+ paneEls[paneIndex].style.removeProperty("--split-pane-order");
250
+ paneEls[paneIndex].style.removeProperty("--split-worker-row");
248
251
  }
249
252
  var runtimeHeaders = host.querySelectorAll(".has-worker-runtime");
250
253
  for (var ri = 0; ri < runtimeHeaders.length; ri++) runtimeHeaders[ri].classList.remove("has-worker-runtime");
@@ -268,10 +271,23 @@ export function syncPairChrome(host, split) {
268
271
  }
269
272
  return;
270
273
  }
274
+ var roles = splitGroupRoles(group);
275
+ if (!roles) return;
276
+ var workerIndex = 0;
271
277
  for (var pi = 0; pi < split.panes.length && pi < headers.length; pi++) {
272
- (function (sessionId, header) {
273
- var isDriver = sessionId === group.pair.driverId;
274
- if (paneEls[pi]) paneEls[pi].classList.add(isDriver ? "split-pane-role-driver" : "split-pane-role-worker");
278
+ var desiredPane = split.panes[pi];
279
+ var desiredPaneEl = null;
280
+ for (var pei = 0; pei < paneEls.length; pei++) {
281
+ if (paneEls[pei].dataset.sessionId === String(desiredPane.sessionId)) desiredPaneEl = paneEls[pei];
282
+ }
283
+ if (!desiredPaneEl) continue;
284
+ (function (sessionId, header, paneEl) {
285
+ var isDriver = sessionId === roles.driverId;
286
+ if (paneEl) {
287
+ paneEl.classList.add(isDriver ? "split-pane-role-driver" : "split-pane-role-worker");
288
+ paneEl.style.setProperty("--split-pane-order", isDriver ? "2" : String(workerIndex));
289
+ if (!isDriver) paneEl.style.setProperty("--split-worker-row", String(workerIndex + 1));
290
+ }
275
291
  var role = isDriver ? "Driver" : "Split Worker";
276
292
  var badge = document.createElement("span");
277
293
  badge.className = "split-pair-role split-pair-role-" + role.toLowerCase().replace(/ /g, "-");
@@ -287,18 +303,24 @@ export function syncPairChrome(host, split) {
287
303
  if (sessions[wi].id === sessionId) { workerSession = sessions[wi]; break; }
288
304
  }
289
305
  appendWorkerRuntime(header, workerSession);
306
+ workerIndex++;
290
307
  }
291
- })(split.panes[pi].sessionId, headers[pi]);
308
+ })(desiredPane.sessionId, desiredPaneEl.querySelector(".split-pane-header"), desiredPaneEl);
292
309
  }
293
310
  var active = (store.get('splitDelegations') || {})[group.id];
294
311
  if (!active || !active.active) return;
295
312
  host.classList.add("split-delegating");
296
313
  var fromIndex = split.panes[0].sessionId === active.from ? 0 : 1;
297
314
  host.classList.add(fromIndex === 0 ? "split-direction-right" : "split-direction-left");
298
- for (var si = 0; si < split.panes.length && si < headers.length; si++) {
315
+ for (var si = 0; si < split.panes.length; si++) {
316
+ var livePane = null;
317
+ for (var lpi = 0; lpi < paneEls.length; lpi++) {
318
+ if (paneEls[lpi].dataset.sessionId === String(split.panes[si].sessionId)) livePane = paneEls[lpi];
319
+ }
320
+ if (!livePane) continue;
299
321
  var live = document.createElement("span");
300
322
  live.className = "split-pair-live";
301
323
  live.textContent = split.panes[si].sessionId === active.to ? "Following direction" : "Waiting";
302
- headers[si].appendChild(live);
324
+ livePane.querySelector(".split-pane-header").appendChild(live);
303
325
  }
304
326
  }
@@ -0,0 +1,28 @@
1
+ // Keyed DOM reconciliation for the parent split shell. Existing pane nodes
2
+ // (and their iframe browsing contexts) are reused by stable project/session
3
+ // identity while metadata and visual order are refreshed.
4
+
5
+ export function splitPaneIdentity(pane) {
6
+ return String(pane && pane.slug || "") + "#" + String(pane && pane.sessionId || "");
7
+ }
8
+
9
+ export function reconcileSplitPanes(host, panes, createPane, updatePane) {
10
+ var desired = Array.isArray(panes) ? panes : [];
11
+ var existing = host.querySelectorAll(".split-pane");
12
+ var byKey = new Map();
13
+ for (var i = 0; i < existing.length; i++) byKey.set(existing[i].dataset.splitPaneKey, existing[i]);
14
+ var used = new Set();
15
+ for (var pi = 0; pi < desired.length; pi++) {
16
+ var pane = desired[pi];
17
+ var key = splitPaneIdentity(pane);
18
+ var paneEl = byKey.get(key);
19
+ if (!paneEl) paneEl = createPane(pane, pi);
20
+ paneEl.dataset.splitPaneKey = key;
21
+ updatePane(paneEl, pane, pi);
22
+ used.add(paneEl);
23
+ if (!paneEl.parentNode) host.appendChild(paneEl);
24
+ }
25
+ for (var ei = existing.length - 1; ei >= 0; ei--) {
26
+ if (!used.has(existing[ei])) existing[ei].remove();
27
+ }
28
+ }