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
@@ -0,0 +1,107 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { getCachedSessions } from './sidebar-sessions.js';
4
+ import { iconHtml } from './icons.js';
5
+ import { formatTokens } from './app-panels.js';
6
+ import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
7
+ import { createPermissionControl, bindPermissionControl, renderPermissionControl } from './permission-control.js';
8
+ import { splitGroupRoles } from './split-group-helpers.js';
9
+
10
+ function sessionById(sessionId) {
11
+ var sessions = getCachedSessions() || [];
12
+ for (var i = 0; i < sessions.length; i++) if (sessions[i].id === sessionId) return sessions[i];
13
+ return null;
14
+ }
15
+
16
+ function paneUrl(pane) {
17
+ return "/p/" + encodeURIComponent(pane.slug) + "/?pane=1&session=" + encodeURIComponent(pane.sessionId);
18
+ }
19
+
20
+ function startPaneRename(header, titleEl, pane) {
21
+ if (header.querySelector(".split-pane-rename-input")) return;
22
+ var input = document.createElement("input");
23
+ input.type = "text"; input.className = "split-pane-rename-input"; input.value = pane.title;
24
+ titleEl.style.display = "none"; header.insertBefore(input, titleEl); input.focus(); input.select();
25
+ var done = false;
26
+ function finish(commit) {
27
+ if (done) return;
28
+ done = true;
29
+ var newTitle = input.value.trim(); input.remove(); titleEl.style.display = "";
30
+ if (!commit || !newTitle || newTitle === pane.title) return;
31
+ pane.title = newTitle; titleEl.textContent = newTitle;
32
+ var ws = getWs();
33
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: "rename_session", id: pane.sessionId, title: newTitle }));
34
+ }
35
+ input.addEventListener("keydown", function (event) {
36
+ if (event.key === "Enter") { event.preventDefault(); finish(true); }
37
+ if (event.key === "Escape") { event.preventDefault(); finish(false); }
38
+ });
39
+ input.addEventListener("blur", function () { finish(true); });
40
+ input.addEventListener("click", function (event) { event.stopPropagation(); });
41
+ }
42
+
43
+ function permissionState(session) {
44
+ var mode = session && (session.runtimeMode || session.mode || "gui");
45
+ var worker = !!session && isConfiguredWorker(store.get('splitGroups'), session.id);
46
+ return {
47
+ projectSlug: store.get('currentSlug'), sessionId: session && session.id,
48
+ vendor: session && session.vendor || store.get('currentVendor') || "claude",
49
+ permissionMode: session && session.permissionMode || "default",
50
+ effectivePermissionMode: session && session.effectivePermissionMode || null,
51
+ permissionCapabilities: session && session.permissionCapabilities || { auto: false, mcpOverride: false },
52
+ connected: store.get('connected'), globalPermissionModeForced: store.get('skipPermsEnabled') === true,
53
+ visible: !!session && !worker && mode === "gui", locked: worker,
54
+ };
55
+ }
56
+
57
+ function isConfiguredWorker(groups, sessionId) {
58
+ for (var i = 0; i < (groups || []).length; i++) {
59
+ var roles = splitGroupRoles(groups[i]);
60
+ if (roles && roles.workerIds.indexOf(sessionId) !== -1) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ export function updatePaneFullAccessButton(button, session) {
66
+ if (button) renderPermissionControl(button, permissionState(session));
67
+ }
68
+
69
+ export function createSplitPane(pane, closePane) {
70
+ var paneEl = document.createElement("section"); paneEl.className = "split-pane";
71
+ var header = document.createElement("header"); header.className = "split-pane-header"; header.title = pane.title;
72
+ var session = sessionById(pane.sessionId); var vendor = session && session.vendor || "claude";
73
+ var vendorIcon = document.createElement("img"); vendorIcon.className = "split-pane-vendor";
74
+ vendorIcon.src = VENDOR_AVATARS[vendor] || VENDOR_AVATARS.claude; vendorIcon.alt = ""; vendorIcon.title = VENDOR_NAMES[vendor] || vendor; header.appendChild(vendorIcon);
75
+ var title = document.createElement("span"); title.className = "split-pane-title"; title.textContent = pane.title; title.title = "Rename session";
76
+ title.addEventListener("click", function () { startPaneRename(header, title, pane); }); header.appendChild(title);
77
+ var frame = document.createElement("iframe"); frame.className = "split-pane-frame"; frame.dataset.projectSlug = pane.slug;
78
+ frame.dataset.sessionId = String(pane.sessionId); frame.src = paneUrl(pane); frame.title = pane.title;
79
+ var ctxChip = document.createElement("button"); ctxChip.type = "button"; ctxChip.className = "split-pane-context"; ctxChip.title = "Context usage";
80
+ ctxChip.innerHTML = '<span class="split-pane-context-bar"><span class="split-pane-context-fill"></span></span><span class="split-pane-context-label"></span>';
81
+ ctxChip.addEventListener("click", function () { if (frame.contentWindow) frame.contentWindow.postMessage({ type: "clay-pane-toggle-context" }, window.location.origin); }); header.appendChild(ctxChip);
82
+ var fullAccess = createPermissionControl("split-pane-full-access hidden", "Driver session permission mode");
83
+ updatePaneFullAccessButton(fullAccess, session); bindPermissionControl(fullAccess, function () { return permissionState(sessionById(pane.sessionId)); }); header.appendChild(fullAccess); header.insertBefore(fullAccess, ctxChip);
84
+ var close = document.createElement("button"); close.type = "button"; close.className = "split-pane-close"; close.title = "Close pane"; close.setAttribute("aria-label", "Close pane"); close.innerHTML = iconHtml("x");
85
+ close.dataset.sessionId = String(pane.sessionId); close.addEventListener("click", function () { closePane(Number(this.dataset.sessionId)); }); header.appendChild(close);
86
+ paneEl.appendChild(header); paneEl.appendChild(frame); return paneEl;
87
+ }
88
+
89
+ export function updateSplitPaneMetadata(paneEl, pane) {
90
+ var session = sessionById(pane.sessionId); var header = paneEl.querySelector(".split-pane-header");
91
+ var title = paneEl.querySelector(".split-pane-title"); var frame = paneEl.querySelector(".split-pane-frame"); var vendor = paneEl.querySelector(".split-pane-vendor");
92
+ paneEl.dataset.sessionId = String(pane.sessionId); paneEl.dataset.projectSlug = pane.slug;
93
+ if (header) header.title = pane.title; if (title && title.style.display !== "none") title.textContent = pane.title; if (frame) frame.title = pane.title;
94
+ if (vendor) { var vendorName = session && session.vendor || "claude"; vendor.src = VENDOR_AVATARS[vendorName] || VENDOR_AVATARS.claude; vendor.title = VENDOR_NAMES[vendorName] || vendorName; }
95
+ var close = paneEl.querySelector(".split-pane-close");
96
+ if (close) {
97
+ close.dataset.sessionId = String(pane.sessionId); close.disabled = false; close.title = "Close pane"; close.setAttribute("aria-label", close.title);
98
+ }
99
+ }
100
+
101
+ export function updatePaneContextChip(paneEl, msg) {
102
+ if (!paneEl) return; var chip = paneEl.querySelector(".split-pane-context"); if (!chip) return; var pct = msg.pct || 0; if (pct <= 0) return;
103
+ chip.classList.add("has-data"); var fill = chip.querySelector(".split-pane-context-fill"); var label = chip.querySelector(".split-pane-context-label");
104
+ fill.style.width = Math.min(100, pct).toFixed(1) + "%"; fill.className = "split-pane-context-fill" + (msg.cls || ""); label.textContent = pct.toFixed(0) + "%";
105
+ var tip = "Context " + pct.toFixed(0) + "% (" + formatTokens(msg.used || 0) + " / " + formatTokens(msg.win || 0) + " tokens)";
106
+ if (msg.cost) tip += " · $" + msg.cost.toFixed(4); if (msg.model && msg.model !== "-") tip += " · " + msg.model; chip.title = tip;
107
+ }
@@ -3,20 +3,19 @@
3
3
  import { store } from './store.js';
4
4
  import { getWs } from './ws-ref.js';
5
5
  import { getCachedSessions } from './sidebar-sessions.js';
6
- import { iconHtml, refreshIcons } from './icons.js';
6
+ import { refreshIcons } from './icons.js';
7
7
  import { detachTuiView } from './session-tui-view.js';
8
- import { formatTokens } from './app-panels.js';
9
- import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
10
- import { groupedSessionIds, findSplitGroup, isConfiguredWorker } from './split-group-helpers.js';
8
+ import { groupedSessionIds, findSplitGroup, isConfiguredWorker, splitGroupRoles, splitGroupMemberIds, splitGroupActiveAnchor, splitWorkerCloseRequest } from './split-group-helpers.js';
11
9
  import { syncPairChrome } from './split-pair-ui.js';
12
10
  import { presentMarkdownEdit } from './filebrowser.js';
13
11
  import { autoStartLoginIfNeeded } from './vendor-login.js';
14
12
  import { isCurrentProjectSessionReady } from './split-session-boundary.js';
15
- import { splitPanesMatchProject, hasCurrentSplitGroup, reconcileRestoredSplit } from './project-activation.js';
13
+ import { splitPanesMatchProject, reconcileRestoredSplit } from './project-activation.js';
16
14
  import { handlePaneIssueMessage } from './pane-issue-bridge.js';
17
15
  import { handlePaneFileMessage, revealFilesPanel } from './pane-file-bridge.js';
18
16
  import { openFile } from './filebrowser.js';
19
- import { bindPermissionControl, createPermissionControl, renderPermissionControl } from './permission-control.js';
17
+ import { reconcileSplitPanes } from './split-pane-reconciler.js';
18
+ import { createSplitPane, updateSplitPaneMetadata, updatePaneContextChip, updatePaneFullAccessButton } from './split-pane-renderer.js';
20
19
 
21
20
  var host = null;
22
21
  var nativeApp = null;
@@ -59,10 +58,6 @@ function paneForSession(sessionId) {
59
58
  };
60
59
  }
61
60
 
62
- function paneUrl(pane) {
63
- return "/p/" + encodeURIComponent(pane.slug) + "/?pane=1&session=" + encodeURIComponent(pane.sessionId);
64
- }
65
-
66
61
  // Arc-style drop preview: hovering one half folds the live app into the
67
62
  // other half and shows a ghost pane where the dragged session will land.
68
63
  function setPreviewSide(side) {
@@ -99,48 +94,28 @@ function switchNativeSession(sessionId, expectedSlug) {
99
94
  }
100
95
  }
101
96
 
102
- function closePane(index) {
97
+ function closePane(sessionId) {
103
98
  var split = store.get('splitPanes');
104
- if (!split || !split.panes || split.panes.length !== 2) return;
99
+ if (!split || !split.panes || split.panes.length < 2) return;
100
+ var index = -1;
101
+ for (var pi = 0; pi < split.panes.length; pi++) if (split.panes[pi].sessionId === sessionId) index = pi;
102
+ if (index === -1) return;
105
103
  if (split.groupId && getWs() && getWs().readyState === 1) {
106
- getWs().send(JSON.stringify({ type: "split_group_dissolve", id: split.groupId }));
107
- }
108
- switchNativeSession(split.panes[index === 0 ? 1 : 0].sessionId, split.panes[index === 0 ? 1 : 0].slug);
109
- }
110
-
111
- function startPaneRename(header, titleEl, pane) {
112
- if (header.querySelector(".split-pane-rename-input")) return;
113
- var input = document.createElement("input");
114
- input.type = "text";
115
- input.className = "split-pane-rename-input";
116
- input.value = pane.title;
117
- titleEl.style.display = "none";
118
- header.insertBefore(input, titleEl);
119
- input.focus();
120
- input.select();
121
-
122
- var done = false;
123
- function finish(commit) {
124
- if (done) return;
125
- done = true;
126
- var newTitle = input.value.trim();
127
- input.remove();
128
- titleEl.style.display = "";
129
- if (!commit || !newTitle || newTitle === pane.title) return;
130
- pane.title = newTitle;
131
- titleEl.textContent = newTitle;
132
- var ws = getWs();
133
- if (ws && ws.readyState === 1) {
134
- ws.send(JSON.stringify({ type: "rename_session", id: pane.sessionId, title: newTitle }));
104
+ var group = null;
105
+ var groups = store.get('splitGroups') || [];
106
+ for (var gi = 0; gi < groups.length; gi++) if (groups[gi].id === split.groupId) group = groups[gi];
107
+ var roles = splitGroupRoles(group);
108
+ var closingId = sessionId;
109
+ if (roles && roles.version === 2 && roles.workerIds.indexOf(closingId) !== -1) {
110
+ var request = splitWorkerCloseRequest(group, getCachedSessions(), store.get('currentSlug'), closingId,
111
+ "worker-close-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8));
112
+ if (request) getWs().send(JSON.stringify(request));
113
+ return;
135
114
  }
115
+ getWs().send(JSON.stringify({ type: "split_group_dissolve", id: split.groupId }));
136
116
  }
137
-
138
- input.addEventListener("keydown", function (event) {
139
- if (event.key === "Enter") { event.preventDefault(); finish(true); }
140
- if (event.key === "Escape") { event.preventDefault(); finish(false); }
141
- });
142
- input.addEventListener("blur", function () { finish(true); });
143
- input.addEventListener("click", function (event) { event.stopPropagation(); });
117
+ var next = split.panes[index === 0 ? 1 : 0];
118
+ switchNativeSession(next.sessionId, next.slug);
144
119
  }
145
120
 
146
121
  // Called on session_list so pane headers follow renames made elsewhere
@@ -163,117 +138,6 @@ export function syncPaneTitles() {
163
138
  syncPairChrome(host, split);
164
139
  }
165
140
 
166
- function panePermissionControlState(session) {
167
- var mode = session && (session.runtimeMode || session.mode || "gui");
168
- var worker = !!session && isConfiguredWorker(store.get('splitGroups'), session.id);
169
- return {
170
- projectSlug: store.get('currentSlug'),
171
- sessionId: session && session.id,
172
- vendor: session && session.vendor || store.get('currentVendor') || "claude",
173
- permissionMode: session && session.permissionMode || "default",
174
- effectivePermissionMode: session && session.effectivePermissionMode || null,
175
- permissionCapabilities: session && session.permissionCapabilities || { auto: false, mcpOverride: false },
176
- connected: store.get('connected'),
177
- globalPermissionModeForced: store.get('skipPermsEnabled') === true,
178
- visible: !!session && !worker && mode === "gui",
179
- locked: worker,
180
- };
181
- }
182
-
183
- function updatePaneFullAccessButton(button, session) {
184
- if (!button) return;
185
- // Permission policy belongs to the Driver. A configured Worker inherits it
186
- // server-side and must not present a second, misleading control.
187
- renderPermissionControl(button, panePermissionControlState(session));
188
- }
189
-
190
- function createPane(pane, index) {
191
- var paneEl = document.createElement("section");
192
- paneEl.className = "split-pane";
193
-
194
- var header = document.createElement("header");
195
- header.className = "split-pane-header";
196
- header.title = pane.title;
197
-
198
- var session = sessionById(pane.sessionId);
199
- var vendor = (session && session.vendor) || "claude";
200
- var vendorIcon = document.createElement("img");
201
- vendorIcon.className = "split-pane-vendor";
202
- vendorIcon.src = VENDOR_AVATARS[vendor] || VENDOR_AVATARS.claude;
203
- vendorIcon.alt = "";
204
- vendorIcon.title = VENDOR_NAMES[vendor] || vendor;
205
- header.appendChild(vendorIcon);
206
-
207
- var title = document.createElement("span");
208
- title.className = "split-pane-title";
209
- title.textContent = pane.title;
210
- title.title = "Rename session";
211
- title.addEventListener("click", function () { startPaneRename(header, title, pane); });
212
- header.appendChild(title);
213
-
214
- var frame = document.createElement("iframe");
215
- frame.className = "split-pane-frame";
216
- frame.dataset.projectSlug = pane.slug;
217
- frame.dataset.sessionId = String(pane.sessionId);
218
- frame.src = paneUrl(pane);
219
- frame.title = pane.title;
220
-
221
- // Context-usage chip, fed by clay-pane-context messages from the pane
222
- // iframe. Hidden until the pane reports a non-zero context percentage.
223
- var ctxChip = document.createElement("button");
224
- ctxChip.type = "button";
225
- ctxChip.className = "split-pane-context";
226
- ctxChip.title = "Context usage";
227
- ctxChip.innerHTML = '<span class="split-pane-context-bar"><span class="split-pane-context-fill"></span></span><span class="split-pane-context-label"></span>';
228
- ctxChip.addEventListener("click", function () {
229
- if (frame.contentWindow) {
230
- frame.contentWindow.postMessage({ type: "clay-pane-toggle-context" }, window.location.origin);
231
- }
232
- });
233
- header.appendChild(ctxChip);
234
-
235
- var fullAccess = createPermissionControl("split-pane-full-access hidden", "Driver session permission mode");
236
- updatePaneFullAccessButton(fullAccess, session);
237
- bindPermissionControl(fullAccess, function() {
238
- var current = sessionById(pane.sessionId);
239
- return panePermissionControlState(current);
240
- });
241
- header.appendChild(fullAccess);
242
- // Permission state belongs to the session identity on the left. Keep it
243
- // immediately after the title instead of grouping it with usage and close.
244
- header.insertBefore(fullAccess, ctxChip);
245
-
246
- var close = document.createElement("button");
247
- close.type = "button";
248
- close.className = "split-pane-close";
249
- close.title = "Close pane";
250
- close.setAttribute("aria-label", "Close pane");
251
- close.innerHTML = iconHtml("x");
252
- close.addEventListener("click", function () { closePane(index); });
253
- header.appendChild(close);
254
- paneEl.appendChild(header);
255
- paneEl.appendChild(frame);
256
- return paneEl;
257
- }
258
-
259
- function updatePaneContextChip(paneEl, msg) {
260
- if (!paneEl) return;
261
- var chip = paneEl.querySelector(".split-pane-context");
262
- if (!chip) return;
263
- var pct = msg.pct || 0;
264
- if (pct <= 0) return;
265
- chip.classList.add("has-data");
266
- var fill = chip.querySelector(".split-pane-context-fill");
267
- var label = chip.querySelector(".split-pane-context-label");
268
- fill.style.width = Math.min(100, pct).toFixed(1) + "%";
269
- fill.className = "split-pane-context-fill" + (msg.cls || "");
270
- label.textContent = pct.toFixed(0) + "%";
271
- var tip = "Context " + pct.toFixed(0) + "% (" + formatTokens(msg.used || 0) + " / " + formatTokens(msg.win || 0) + " tokens)";
272
- if (msg.cost) tip += " · $" + msg.cost.toFixed(4);
273
- if (msg.model && msg.model !== "-") tip += " · " + msg.model;
274
- chip.title = tip;
275
- }
276
-
277
141
  function handlePaneMessage(event) {
278
142
  if (event.origin !== window.location.origin) return;
279
143
  var msg = event.data;
@@ -301,34 +165,20 @@ function handlePaneMessage(event) {
301
165
  }
302
166
  }
303
167
 
304
- var renderedPaneKey = null;
305
-
306
- function paneKey(panes) {
307
- return panes.map(function (pane) { return pane.slug + "#" + pane.sessionId; }).join("|");
308
- }
309
-
310
168
  function renderSplit(split) {
311
169
  if (!host || !nativeApp) return;
312
170
  var panes = split && split.panes;
313
- if (!panes || panes.length !== 2) {
171
+ if (!panes || panes.length < 2 || panes.length > 3) {
314
172
  placeStickyNotesOverlay(false);
315
173
  host.innerHTML = "";
316
- renderedPaneKey = null;
317
174
  host.classList.remove("visible");
318
175
  nativeApp.classList.remove("split-native-hidden");
319
176
  return;
320
177
  }
321
- // splitPanes is replaced (same panes, new object) when the server confirms
322
- // the groupId. Rebuilding then would reload both iframes, so skip DOM work
323
- // when the rendered pane set is unchanged.
324
- var key = paneKey(panes);
325
178
  placeStickyNotesOverlay(true);
326
- if (key === renderedPaneKey && host.classList.contains("visible")) return;
327
- host.innerHTML = "";
328
- renderedPaneKey = key;
329
179
  nativeApp.classList.add("split-native-hidden");
330
180
  host.classList.add("visible");
331
- for (var i = 0; i < panes.length; i++) host.appendChild(createPane(panes[i], i));
181
+ reconcileSplitPanes(host, panes, function (pane) { return createSplitPane(pane, closePane); }, updateSplitPaneMetadata);
332
182
  syncPairChrome(host, split);
333
183
  refreshIcons();
334
184
  }
@@ -360,23 +210,26 @@ function openSplit(side, draggedId) {
360
210
  }
361
211
 
362
212
  export function openGroup(group) {
363
- if (!group || !Array.isArray(group.members) || group.members.length !== 2) return false;
364
- if (!sessionById(group.members[0]) || !sessionById(group.members[1])) return false;
213
+ if (!group || !Array.isArray(group.members) || group.members.length < 2 || group.members.length > 3) return false;
214
+ var memberIds = splitGroupMemberIds(group);
215
+ if (memberIds.length !== group.members.length) return false;
216
+ for (var mi = 0; mi < memberIds.length; mi++) if (!sessionById(memberIds[mi])) return false;
365
217
  detachTuiView();
366
218
  store.set({
367
219
  splitPanes: {
368
220
  groupId: group.id,
369
- panes: [paneForSession(group.members[0]), paneForSession(group.members[1])],
221
+ panes: memberIds.map(function (id) { return paneForSession(id); }),
370
222
  },
371
223
  });
372
224
  // Anchor the parent's active session (and server-side presence) to a
373
225
  // member while the split is open, so a hard refresh restores into this
374
226
  // group instead of whatever was viewed before it.
375
227
  var activeId = store.get('activeSessionId');
376
- if (activeId !== group.members[0] && activeId !== group.members[1]) {
228
+ var anchorId = splitGroupActiveAnchor(group, activeId);
229
+ if (anchorId && anchorId !== activeId) {
377
230
  var ws = getWs();
378
231
  if (isCurrentProjectSessionReady(store.snap(), ws)) {
379
- ws.send(JSON.stringify({ type: "switch_session", id: group.members[0] }));
232
+ ws.send(JSON.stringify({ type: "switch_session", id: anchorId }));
380
233
  }
381
234
  }
382
235
  dismissSplitOverlays();
@@ -403,13 +256,13 @@ export function maybeRestoreSplitGroup() {
403
256
  }
404
257
  if (reconciliation.action === "rebuild") {
405
258
  var rebuilt = reconciliation.group;
406
- if (!sessionById(rebuilt.members[0]) || !sessionById(rebuilt.members[1])) {
259
+ if (rebuilt.members.some(function (id) { return !sessionById(id); })) {
407
260
  store.set({ splitPanes: null });
408
261
  return;
409
262
  }
410
263
  store.set({ splitPanes: {
411
264
  groupId: rebuilt.id,
412
- panes: [paneForSession(rebuilt.members[0]), paneForSession(rebuilt.members[1])],
265
+ panes: splitGroupMemberIds(rebuilt).map(function (id) { return paneForSession(id); }),
413
266
  } });
414
267
  }
415
268
  return;
@@ -427,13 +280,13 @@ export function maybeRestoreSplitGroup() {
427
280
  }
428
281
 
429
282
  export function separateGroup(group) {
430
- if (!group || !Array.isArray(group.members) || group.members.length !== 2) return;
283
+ if (!group || !Array.isArray(group.members) || group.members.length < 2) return;
431
284
  var ws = getWs();
432
285
  if (ws && ws.readyState === 1) {
433
286
  ws.send(JSON.stringify({ type: "split_group_dissolve", id: group.id }));
434
287
  }
435
288
  var split = store.get('splitPanes');
436
- if (split && split.groupId === group.id) switchNativeSession(group.members[0]);
289
+ if (split && split.groupId === group.id) switchNativeSession(split.panes[0].sessionId);
437
290
  }
438
291
 
439
292
  function dismissSplitOverlays() {
@@ -531,24 +384,41 @@ export function initSplitView() {
531
384
  // notification click) closes the split UI; the group itself persists.
532
385
  if (state.activeSessionId !== prev.activeSessionId && state.splitPanes && state.splitPanes.panes) {
533
386
  var sp = state.splitPanes.panes;
534
- if (state.activeSessionId !== sp[0].sessionId && state.activeSessionId !== sp[1].sessionId) {
387
+ var activePane = false;
388
+ for (var spi = 0; spi < sp.length; spi++) if (state.activeSessionId === sp[spi].sessionId) activePane = true;
389
+ if (!activePane) {
535
390
  store.set({ splitPanes: null });
536
391
  }
537
392
  }
538
393
  if (state.splitGroups !== prev.splitGroups) {
539
394
  if (!isCurrentProjectSessionReady(state, getWs())) return;
540
395
  var split = state.splitPanes;
541
- if (!split || !split.panes) return;
396
+ if (!split || !split.panes || split.panes.length < 2) return;
542
397
  if (!splitPanesMatchProject(split, state.currentSlug)) {
543
398
  store.set({ splitPanes: null });
544
399
  return;
545
400
  }
546
401
  syncPairChrome(host, split);
547
402
  if (split.groupId) {
548
- if (!hasCurrentSplitGroup(split, state.splitGroups)) switchNativeSession(split.panes[0].sessionId, state.currentSlug);
403
+ var currentGroup = null;
404
+ for (var cgi = 0; cgi < state.splitGroups.length; cgi++) {
405
+ if (state.splitGroups[cgi].id === split.groupId) currentGroup = state.splitGroups[cgi];
406
+ }
407
+ if (!currentGroup) {
408
+ switchNativeSession(split.panes[0].sessionId, state.currentSlug);
409
+ return;
410
+ }
411
+ var projectedIds = splitGroupMemberIds(currentGroup);
412
+ if (projectedIds.length < 2 || projectedIds.length > 3) return;
413
+ var projectedPanes = projectedIds.map(function (id) { return paneForSession(id); });
414
+ var projectedKey = projectedPanes.map(function (pane) { return pane.slug + "#" + pane.sessionId; }).join("|");
415
+ var currentKey = split.panes.map(function (pane) { return pane.slug + "#" + pane.sessionId; }).join("|");
416
+ if (projectedKey !== currentKey) {
417
+ store.set({ splitPanes: { groupId: split.groupId, panes: projectedPanes } });
418
+ }
549
419
  return;
550
420
  }
551
- var ids = [split.panes[0].sessionId, split.panes[1].sessionId];
421
+ var ids = split.panes.map(function (pane) { return pane.sessionId; });
552
422
  var confirmed = findSplitGroup(state.splitGroups, ids);
553
423
  if (confirmed) store.set({ splitPanes: { groupId: confirmed.id, panes: split.panes } });
554
424
  }
@@ -0,0 +1,126 @@
1
+ // websocket-watchdog.js - suspendable liveness timers for the browser socket
2
+
3
+ function createWebSocketWatchdog(options) {
4
+ var suspended = false;
5
+ var heartbeatTimer = null;
6
+ var heartbeatDeadlineTimer = null;
7
+ var heartbeatSocket = null;
8
+ var heartbeatEpoch = 0;
9
+ var heartbeatGeneration = 0;
10
+ var deadlineGeneration = 0;
11
+ var probeTimer = null;
12
+ var probeSocket = null;
13
+ var probeEpoch = 0;
14
+ var probeStartedAt = 0;
15
+ var probeGeneration = 0;
16
+
17
+ function clearHeartbeatDeadline() {
18
+ deadlineGeneration += 1;
19
+ if (heartbeatDeadlineTimer) clearTimeout(heartbeatDeadlineTimer);
20
+ heartbeatDeadlineTimer = null;
21
+ }
22
+
23
+ function stopHeartbeat() {
24
+ heartbeatGeneration += 1;
25
+ clearHeartbeatDeadline();
26
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
27
+ heartbeatTimer = null;
28
+ heartbeatSocket = null;
29
+ heartbeatEpoch = 0;
30
+ }
31
+
32
+ function clearProbe() {
33
+ probeGeneration += 1;
34
+ if (probeTimer) clearTimeout(probeTimer);
35
+ probeTimer = null;
36
+ probeSocket = null;
37
+ probeEpoch = 0;
38
+ probeStartedAt = 0;
39
+ }
40
+
41
+ function suspend() {
42
+ suspended = true;
43
+ stopHeartbeat();
44
+ clearProbe();
45
+ }
46
+
47
+ function resume() {
48
+ var changed = suspended;
49
+ suspended = false;
50
+ return changed;
51
+ }
52
+
53
+ function startHeartbeat(socket, epoch) {
54
+ stopHeartbeat();
55
+ if (suspended || !socket) return false;
56
+ heartbeatSocket = socket;
57
+ heartbeatEpoch = epoch;
58
+ var generation = heartbeatGeneration;
59
+ heartbeatTimer = setInterval(function () {
60
+ if (suspended || generation !== heartbeatGeneration) return;
61
+ if (!options.isCurrent(socket, epoch)) {
62
+ stopHeartbeat();
63
+ return;
64
+ }
65
+ clearHeartbeatDeadline();
66
+ var currentDeadlineGeneration = deadlineGeneration;
67
+ heartbeatDeadlineTimer = setTimeout(function () {
68
+ if (suspended || currentDeadlineGeneration !== deadlineGeneration) return;
69
+ heartbeatDeadlineTimer = null;
70
+ if (heartbeatSocket === socket && heartbeatEpoch === epoch && options.isCurrent(socket, epoch)) {
71
+ options.onFailure("heartbeat_timeout", epoch, null);
72
+ }
73
+ }, options.heartbeatDeadlineMs);
74
+ try {
75
+ options.sendPing(socket);
76
+ } catch (e) {
77
+ options.onFailure("heartbeat_error", epoch, null);
78
+ }
79
+ }, options.heartbeatIntervalMs);
80
+ return true;
81
+ }
82
+
83
+ function probe(socket, epoch) {
84
+ if (suspended) return "suspended";
85
+ if (probeTimer) return "pending";
86
+ probeSocket = socket;
87
+ probeEpoch = epoch;
88
+ probeStartedAt = options.now();
89
+ probeGeneration += 1;
90
+ var generation = probeGeneration;
91
+ probeTimer = setTimeout(function () {
92
+ if (suspended || generation !== probeGeneration) return;
93
+ probeTimer = null;
94
+ if (probeSocket === socket && probeEpoch === epoch && options.isCurrent(socket, epoch)) {
95
+ options.onFailure("probe_timeout", epoch, options.now() - probeStartedAt);
96
+ }
97
+ }, options.probeTimeoutMs);
98
+ try {
99
+ options.sendPing(socket);
100
+ } catch (e) {
101
+ if (generation !== probeGeneration) return "cleared";
102
+ var latency = options.now() - probeStartedAt;
103
+ clearProbe();
104
+ options.onFailure("probe_error", epoch, latency);
105
+ return "failed";
106
+ }
107
+ return "started";
108
+ }
109
+
110
+ function acceptPong(socket, epoch) {
111
+ if (heartbeatSocket === socket && heartbeatEpoch === epoch) clearHeartbeatDeadline();
112
+ if (probeSocket === socket && probeEpoch === epoch) clearProbe();
113
+ }
114
+
115
+ return {
116
+ acceptPong: acceptPong,
117
+ clearProbe: clearProbe,
118
+ probe: probe,
119
+ resume: resume,
120
+ startHeartbeat: startHeartbeat,
121
+ stopHeartbeat: stopHeartbeat,
122
+ suspend: suspend
123
+ };
124
+ }
125
+
126
+ export { createWebSocketWatchdog };
@@ -27,6 +27,18 @@ import { store } from './store.js';
27
27
  var STATUS_ID = "worker-pane-status";
28
28
  var LOCK_CLASS = "worker-pane-locked";
29
29
 
30
+ function configuredWorkerIds(group) {
31
+ if (!group || !group.pair || !Array.isArray(group.members)) return [];
32
+ var pair = group.pair;
33
+ var ids = Array.isArray(pair.workerIds) ? pair.workerIds :
34
+ (pair.workerId !== undefined ? [pair.workerId] : []);
35
+ if (pair.driverId === undefined || ids.length < 1 || ids.length > 2 || group.members.indexOf(pair.driverId) === -1) return [];
36
+ for (var i = 0; i < ids.length; i++) {
37
+ if (ids[i] === pair.driverId || group.members.indexOf(ids[i]) === -1) return [];
38
+ }
39
+ return ids;
40
+ }
41
+
30
42
  // Every interactive composer affordance. Queried lazily: a pane iframe and the
31
43
  // main window share this markup, and some ids are mobile-only.
32
44
  var LOCKED_CONTROL_IDS = [
@@ -64,9 +76,7 @@ export function isDriverOperatedView(state) {
64
76
  if (!sessionId) return false;
65
77
  var groups = snapshot.splitGroups || [];
66
78
  for (var i = 0; i < groups.length; i++) {
67
- var pair = groups[i] && groups[i].pair;
68
- if (!pair) continue;
69
- if (pair.workerId === sessionId) return true;
79
+ if (configuredWorkerIds(groups[i]).indexOf(sessionId) !== -1) return true;
70
80
  }
71
81
  return false;
72
82
  }
package/lib/server.js CHANGED
@@ -1336,6 +1336,7 @@ function createServer(opts) {
1336
1336
  worktreeMeta: worktreeMeta || null,
1337
1337
  mcpBridgeToken: extra.mcpBridgeToken || null,
1338
1338
  isMate: extra.isMate || false,
1339
+ multiWorkerRuntimeEnabled: !extra.isMate,
1339
1340
  mateId: extra.mateId || (extra.isMate ? path.basename(cwd) : null),
1340
1341
  mateDisplayName: extra.mateDisplayName || "",
1341
1342
  isHostAgent: !!extra.isHostAgent,