clay-server 4.2.0 → 4.3.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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 +86 -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/multi-worker-feature.js +11 -0
  10. package/lib/project-connection.js +3 -1
  11. package/lib/project-driver-continuation.js +499 -0
  12. package/lib/project-message-delivery.js +20 -0
  13. package/lib/project-pair-autonomous-stop.js +14 -8
  14. package/lib/project-pair-close-transaction.js +59 -0
  15. package/lib/project-pair-global-stop.js +18 -0
  16. package/lib/project-pair-lifecycle-status.js +34 -0
  17. package/lib/project-pair-lifecycle.js +117 -135
  18. package/lib/project-pair-message.js +1 -1
  19. package/lib/project-pair-owned-stop.js +7 -4
  20. package/lib/project-pair-replacement-state.js +5 -1
  21. package/lib/project-pair-result-capture.js +116 -0
  22. package/lib/project-pair-result-delivery.js +140 -0
  23. package/lib/project-pair-result-outbox.js +400 -0
  24. package/lib/project-pair-result-pipeline.js +16 -0
  25. package/lib/project-pair-result-recovery.js +229 -0
  26. package/lib/project-pair-structural-close.js +20 -0
  27. package/lib/project-pair-task-control.js +45 -18
  28. package/lib/project-pair-worker-close.js +68 -0
  29. package/lib/project-session-handoff.js +48 -55
  30. package/lib/project-session-pair-prompt.js +26 -0
  31. package/lib/project-session-pair.js +87 -106
  32. package/lib/project-sessions.js +4 -0
  33. package/lib/project-user-message.js +21 -0
  34. package/lib/project-worker-creation-proposal.js +176 -0
  35. package/lib/project-worker-permission.js +7 -8
  36. package/lib/project-worker-proposal-runner.js +45 -0
  37. package/lib/project-worker-proposal.js +79 -115
  38. package/lib/project.js +87 -3
  39. package/lib/public/app.js +1 -0
  40. package/lib/public/css/driver-continuation.css +56 -0
  41. package/lib/public/css/messages.css +12 -0
  42. package/lib/public/css/mobile-nav.css +29 -14
  43. package/lib/public/css/overlays.css +54 -25
  44. package/lib/public/css/pane.css +66 -12
  45. package/lib/public/css/sidebar.css +62 -7
  46. package/lib/public/index.html +3 -3
  47. package/lib/public/modules/app-connection.js +165 -74
  48. package/lib/public/modules/app-messages.js +31 -3
  49. package/lib/public/modules/driver-continuation-state.js +76 -0
  50. package/lib/public/modules/driver-continuation.js +260 -0
  51. package/lib/public/modules/message-delivery-ui.js +63 -0
  52. package/lib/public/modules/message-delivery.js +163 -6
  53. package/lib/public/modules/pair-result-status.js +87 -0
  54. package/lib/public/modules/permission-control.js +10 -5
  55. package/lib/public/modules/project-activation.js +10 -7
  56. package/lib/public/modules/session-creation-cta.js +46 -0
  57. package/lib/public/modules/sidebar-mobile.js +42 -30
  58. package/lib/public/modules/sidebar-sessions.js +77 -52
  59. package/lib/public/modules/split-group-helpers.js +74 -2
  60. package/lib/public/modules/split-pair-ui.js +28 -6
  61. package/lib/public/modules/split-pane-reconciler.js +28 -0
  62. package/lib/public/modules/split-pane-renderer.js +107 -0
  63. package/lib/public/modules/split-view.js +58 -188
  64. package/lib/public/modules/websocket-lifecycle.js +178 -0
  65. package/lib/public/modules/websocket-watchdog.js +126 -0
  66. package/lib/public/modules/worker-pane-lock.js +13 -3
  67. package/lib/public/style.css +1 -0
  68. package/lib/sdk-bridge.js +77 -0
  69. package/lib/server.js +1 -0
  70. package/lib/session-handoff-discovery.js +443 -0
  71. package/lib/session-handoff-mcp-server.js +32 -4
  72. package/lib/session-pair-factory.js +66 -0
  73. package/lib/session-pair-mcp-server.js +10 -5
  74. package/lib/session-pair-prompts.js +46 -25
  75. package/lib/session-pair-target.js +42 -0
  76. package/lib/session-pair-turn-control.js +30 -9
  77. package/lib/session-split-group-anchors.js +126 -0
  78. package/lib/session-split-group-persistence.js +136 -0
  79. package/lib/session-split-group-roles.js +164 -0
  80. package/lib/session-split-group-v2-store.js +146 -0
  81. package/lib/session-split-groups.js +293 -133
  82. package/lib/sessions.js +89 -22
  83. package/lib/ws-schema.js +8 -0
  84. package/package.json +1 -1
@@ -1262,26 +1262,81 @@
1262
1262
  opacity: 1;
1263
1263
  }
1264
1264
 
1265
- /* Split button: main action + chevron sharing one grid cell. */
1265
+ .session-top-action:focus-visible {
1266
+ outline: 2px solid var(--accent);
1267
+ outline-offset: 2px;
1268
+ }
1269
+
1270
+ /* Primary creation action with an adjacent icon-only provider selector. */
1266
1271
  .session-top-action-split {
1267
1272
  display: flex;
1273
+ flex-wrap: nowrap;
1268
1274
  align-items: stretch;
1269
- gap: 2px;
1275
+ gap: 0;
1270
1276
  min-width: 0;
1271
1277
  }
1272
1278
 
1273
1279
  .session-top-action-split .split-main {
1274
1280
  flex: 1 1 auto;
1275
1281
  width: auto;
1282
+ min-width: 0;
1283
+ padding: 0 9px;
1284
+ gap: 6px;
1285
+ font-size: 12px;
1286
+ line-height: 1.2;
1287
+ border: 1px solid var(--border-subtle);
1288
+ background: color-mix(in srgb, var(--sidebar-hover) 46%, transparent);
1289
+ color: var(--text);
1290
+ font-weight: 500;
1291
+ white-space: normal;
1292
+ text-align: left;
1293
+ border-radius: 9px 0 0 9px;
1276
1294
  }
1277
1295
 
1278
- .session-top-action-split .split-chevron {
1279
- flex: 0 0 auto;
1280
- width: 28px;
1281
- min-width: 28px;
1296
+ .session-top-action-split .split-main:hover {
1297
+ border-color: var(--border);
1298
+ background: var(--sidebar-hover);
1299
+ }
1300
+
1301
+ .session-top-action-split .split-provider {
1302
+ flex: 0 0 26px;
1303
+ width: 26px;
1304
+ min-width: 26px;
1282
1305
  padding: 0;
1306
+ border: 1px solid var(--border-subtle);
1307
+ border-left-color: var(--border);
1308
+ background: color-mix(in srgb, var(--sidebar-hover) 46%, transparent);
1309
+ border-radius: 0 9px 9px 0;
1310
+ display: inline-flex;
1311
+ align-items: center;
1312
+ gap: 2px;
1283
1313
  justify-content: center;
1284
- gap: 0;
1314
+ }
1315
+
1316
+ .session-top-action-split .split-provider:hover {
1317
+ background: var(--sidebar-hover);
1318
+ }
1319
+
1320
+ .session-create-provider-icon {
1321
+ width: 16px;
1322
+ height: 16px;
1323
+ margin-right: 0;
1324
+ border-radius: 4px;
1325
+ object-fit: cover;
1326
+ }
1327
+
1328
+ .session-create-provider-chevron {
1329
+ display: inline-flex;
1330
+ }
1331
+
1332
+ .session-create-provider-chevron .lucide,
1333
+ .session-create-provider-chevron svg {
1334
+ width: 14px;
1335
+ height: 14px;
1336
+ }
1337
+
1338
+ .session-create-provider[aria-expanded="true"] {
1339
+ background: var(--sidebar-hover);
1285
1340
  }
1286
1341
 
1287
1342
  .session-favorites-section {
@@ -410,9 +410,9 @@
410
410
  <button id="header-info-btn" type="button" title="Session info"><i data-lucide="info"></i></button>
411
411
  <div id="header-full-access-btn" class="session-permission-control hidden" role="group" aria-label="Session permission mode">
412
412
  <div class="session-permission-segmented">
413
- <button type="button" data-permission-mode="default">Ask</button>
414
- <button type="button" data-permission-mode="auto">Auto</button>
415
- <button type="button" data-permission-mode="bypassPermissions" aria-label="Skip permissions"><span class="permission-label-long">Skip permissions</span><span class="permission-label-short" aria-hidden="true">Skip</span></button>
413
+ <button type="button" data-permission-mode="default" aria-label="Ask permissions" title="Ask permissions"><span class="permission-label">Ask</span><span class="permission-spinner" aria-hidden="true"></span></button>
414
+ <button type="button" data-permission-mode="auto" aria-label="Auto permissions" title="Auto permissions"><span class="permission-label">Auto</span><span class="permission-spinner" aria-hidden="true"></span></button>
415
+ <button type="button" data-permission-mode="bypassPermissions" aria-label="Skip permissions" title="Skip permissions"><span class="permission-label">Skip</span><span class="permission-spinner" aria-hidden="true"></span></button>
416
416
  </div>
417
417
  <span class="session-permission-status hidden" aria-live="polite"></span>
418
418
  </div>
@@ -22,10 +22,9 @@ import { beginDefaultVendorConnection, requestDefaultVendor } from './default-ve
22
22
  import { clearProjectSplitState } from './split-session-boundary.js';
23
23
  import { clearPermissionModePending } from './permission-control.js';
24
24
  import { clearMcpPermissionModePending } from './mcp-ui.js';
25
+ import { createWebSocketLifecycle } from './websocket-lifecycle.js';
26
+ import { createWebSocketWatchdog } from './websocket-watchdog.js';
25
27
 
26
- var reconnectTimer = null;
27
- var reconnectDelay = 1000;
28
- var connectTimeoutId = null;
29
28
  var connectOverlay = null;
30
29
  var hasConnectedOnce = false;
31
30
 
@@ -44,9 +43,32 @@ var overlayGraceTimer = null;
44
43
  // server-global-ws.js. Well under the common 60s proxy read timeout, and one
45
44
  // timer per live socket.
46
45
  var HEARTBEAT_MS = 25000;
47
- var heartbeatTimer = null;
48
- var heartbeatDeadlineTimer = null;
49
46
  var disconnectedAt = 0;
47
+ var attemptStartedAt = 0;
48
+ var attemptSocket = null;
49
+ var lifecycle = createWebSocketLifecycle({
50
+ handshakeTimeoutMs: 12000,
51
+ onHandshakeTimeout: function (epoch) {
52
+ if (lifecycle.current(epoch) && attemptSocket) {
53
+ forceReplaceSocket("handshake_timeout", 0, epoch);
54
+ }
55
+ }
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
+ });
50
72
 
51
73
  function clearOverlayGrace() {
52
74
  if (overlayGraceTimer) {
@@ -64,15 +86,54 @@ function showOverlayNow() {
64
86
  connectOverlay.classList.remove("hidden");
65
87
  }
66
88
 
67
- export function stopHeartbeat() {
68
- if (heartbeatTimer) {
69
- clearInterval(heartbeatTimer);
70
- heartbeatTimer = null;
89
+ function recordConnectionDiagnostic(reason, code, latency) {
90
+ var safeReasons = { close: true, error: true, handshake_timeout: true, heartbeat_timeout: true, heartbeat_error: true, probe_error: true, probe_timeout: true, auth_timeout: true, "ack-timeout": true, online: true, visible: true };
91
+ if (!safeReasons[reason]) reason = "unknown";
92
+ console.warn("[clay] WebSocket diagnostic", {
93
+ reason: reason,
94
+ code: typeof code === "number" ? code : 0,
95
+ retry: lifecycle.getRetryAttempt(),
96
+ latencyMs: typeof latency === "number" ? Math.max(0, Math.min(latency, 600000)) : null
97
+ });
98
+ }
99
+
100
+ function forceReplaceSocket(reason, code, epoch, latency) {
101
+ if (!lifecycle.current(epoch)) return;
102
+ var socket = getWs();
103
+ watchdog.clearProbe();
104
+ stopHeartbeat();
105
+ if (socket) {
106
+ socket.onopen = null;
107
+ socket.onmessage = null;
108
+ socket.onerror = null;
109
+ socket.onclose = null;
110
+ try { socket.close(); } catch (e) {}
71
111
  }
72
- if (heartbeatDeadlineTimer) {
73
- clearTimeout(heartbeatDeadlineTimer);
74
- heartbeatDeadlineTimer = null;
112
+ setWs(null);
113
+ recordConnectionDiagnostic(reason, code, latency);
114
+ setStatus("disconnected");
115
+ lifecycle.invalidate(epoch);
116
+ scheduleReconnect(reason);
117
+ }
118
+
119
+ export function stopHeartbeat() {
120
+ watchdog.stopHeartbeat();
121
+ }
122
+
123
+ function suspendSocketForOffline() {
124
+ watchdog.clearProbe();
125
+ stopHeartbeat();
126
+ var socket = getWs();
127
+ attemptSocket = null;
128
+ if (socket) {
129
+ socket.onopen = null;
130
+ socket.onmessage = null;
131
+ socket.onerror = null;
132
+ socket.onclose = null;
133
+ try { socket.close(); } catch (e) {}
75
134
  }
135
+ setWs(null);
136
+ if (socket || store.get('connected')) setStatus("disconnected");
76
137
  }
77
138
 
78
139
  // Started once per live socket. The socket it was started for is captured, so
@@ -80,28 +141,51 @@ export function stopHeartbeat() {
80
141
  // connection. The pong the server sends back keeps a proxy's upstream read
81
142
  // timeout from expiring. A missing pong also identifies a half-open socket,
82
143
  // which browsers can otherwise leave OPEN while silently losing sends.
83
- export function startHeartbeat(socket) {
84
- stopHeartbeat();
85
- if (!socket) return;
86
- heartbeatTimer = setInterval(function () {
87
- if (!socket || socket.readyState !== 1 || getWs() !== socket) {
88
- stopHeartbeat();
89
- return;
90
- }
91
- try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {}
92
- if (heartbeatDeadlineTimer) clearTimeout(heartbeatDeadlineTimer);
93
- heartbeatDeadlineTimer = setTimeout(function () {
94
- heartbeatDeadlineTimer = null;
95
- if (getWs() === socket && socket.readyState === 1) connect();
96
- }, 10000);
97
- }, HEARTBEAT_MS);
144
+ export function startHeartbeat(socket, attemptEpoch) {
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);
98
159
  }
99
160
 
100
161
  export function initConnection() {
101
162
  connectOverlay = document.getElementById("connect-overlay");
163
+ if (typeof navigator !== "undefined" && navigator.onLine === false) lifecycle.setOffline(true);
164
+ if (document.hidden) suspendSocketWatchdogs();
102
165
  window.addEventListener("clay-message-delivery-timeout", function (event) {
103
166
  var socket = event.detail && event.detail.socket;
104
- if (!socket || getWs() === socket) connect();
167
+ recordConnectionDiagnostic("ack-timeout", 0, null);
168
+ if (!socket || getWs() === socket) probeReconnect("ack-timeout");
169
+ });
170
+ window.addEventListener("offline", function () {
171
+ lifecycle.setOffline(true);
172
+ suspendSocketForOffline();
173
+ });
174
+ window.addEventListener("online", function () {
175
+ lifecycle.wake(function () {
176
+ if (document.hidden) return;
177
+ resumeSocketWatchdogs("online");
178
+ });
179
+ });
180
+ document.addEventListener("visibilitychange", function () {
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");
105
189
  });
106
190
 
107
191
  // --- Reactive UI sync for connected/processing state ---
@@ -224,6 +308,9 @@ function onConnected() {
224
308
  }
225
309
 
226
310
  export function connect() {
311
+ if (lifecycle.isOffline()) return;
312
+ var attemptEpoch = lifecycle.beginAttempt();
313
+ attemptStartedAt = Date.now();
227
314
  var ws = getWs();
228
315
  // Tear down the previous socket's heartbeat before a new socket exists, so
229
316
  // two sockets can never both be pinging.
@@ -236,37 +323,24 @@ export function connect() {
236
323
  ws.onclose = null;
237
324
  ws.close();
238
325
  }
239
- if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
240
-
241
326
  var protocol = location.protocol === "https:" ? "wss:" : "ws:";
242
327
  var socketPath = store.get('wsPath');
243
328
  if (store.get('socketPath') && store.get('socketPath') !== socketPath) clearProjectSplitState();
244
329
  store.set({ socketPath: socketPath, activeProjectSlug: null, sessionActivatedProjectSlug: null, sessionListProjectSlug: null, splitGroupsProjectSlug: null });
245
330
  var newWs = new WebSocket(protocol + "//" + location.host + socketPath);
246
331
  setWs(newWs);
247
-
248
- // If not connected within 3s, force retry
249
- connectTimeoutId = setTimeout(function () {
250
- if (getWs() !== newWs) return;
251
- if (!store.get('connected')) {
252
- newWs.onclose = null;
253
- newWs.onerror = null;
254
- newWs.close();
255
- connect();
256
- }
257
- }, 3000);
332
+ attemptSocket = newWs;
258
333
 
259
334
  newWs.onopen = function () {
260
- if (getWs() !== newWs) return;
261
- if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
335
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
336
+ if (!lifecycle.markOpen(attemptEpoch)) return;
262
337
  if (hasConnectedOnce && disconnectedAt) {
263
338
  console.log("[clay] WebSocket reconnected after " + (Date.now() - disconnectedAt) + "ms");
264
339
  }
265
340
  disconnectedAt = 0;
266
341
  setStatus("connected");
267
- startHeartbeat(newWs);
268
- reconnectDelay = 1000;
269
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
342
+ startHeartbeat(newWs, attemptEpoch);
343
+ lifecycle.clearRetry();
270
344
 
271
345
  // A pane pin is one-shot per WebSocket, not per page lifetime. The server
272
346
  // intentionally does not restore pane presence after a daemon restart.
@@ -292,64 +366,81 @@ export function connect() {
292
366
  };
293
367
 
294
368
  newWs.onclose = function (e) {
295
- if (getWs() !== newWs) return;
296
- if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
369
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
297
370
  stopHeartbeat();
371
+ watchdog.clearProbe();
298
372
  disconnectedAt = Date.now();
299
- // Abnormal closes are the ones worth reporting: a clean close is either a
300
- // deliberate navigation or a server shutdown the user already knows about.
301
- if (e && !e.wasClean) {
302
- console.warn("[clay] WebSocket closed abnormally: code=" + e.code +
303
- " reason=" + (e.reason || "(none)") + " wasClean=false");
304
- }
373
+ recordConnectionDiagnostic("close", e && e.code, attemptStartedAt ? Date.now() - attemptStartedAt : null);
305
374
  closeDmUserPicker();
306
375
  store.set({ activeProjectSlug: null, sessionActivatedProjectSlug: null });
307
376
  setStatus("disconnected");
308
377
  setActivity(null);
309
- scheduleReconnect();
378
+ lifecycle.invalidate(attemptEpoch);
379
+ scheduleReconnect("close");
310
380
  };
311
381
 
312
- newWs.onerror = function () {};
382
+ newWs.onerror = function () {
383
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
384
+ recordConnectionDiagnostic("error", 0, null);
385
+ forceReplaceSocket("error", 0, attemptEpoch);
386
+ };
313
387
 
314
388
  newWs.onmessage = function (event) {
315
- if (getWs() !== newWs) return;
389
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
316
390
  // Backup: if we're receiving messages, we're connected
317
391
  if (!store.get('connected')) {
318
392
  setStatus("connected");
319
- reconnectDelay = 1000;
320
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
393
+ lifecycle.clearRetry();
321
394
  }
322
395
 
323
396
  blinkIO();
324
397
  var msg;
325
398
  try { msg = JSON.parse(event.data); } catch (e) { return; }
326
- if (msg.type === "pong" && heartbeatDeadlineTimer) {
327
- clearTimeout(heartbeatDeadlineTimer);
328
- heartbeatDeadlineTimer = null;
329
- }
399
+ lifecycle.markLive(attemptEpoch);
400
+ if (msg.type === "pong") watchdog.acceptPong(newWs, attemptEpoch);
330
401
  processMessage(msg);
331
402
  };
332
403
  }
333
404
 
334
405
  export function cancelReconnect() {
335
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
406
+ watchdog.clearProbe();
407
+ stopHeartbeat();
408
+ lifecycle.cancel();
409
+ }
410
+
411
+ function probeReconnect(reason) {
412
+ if (lifecycle.isOffline() || !lifecycle.current(lifecycle.getEpoch())) return;
413
+ var socket = getWs();
414
+ if (socket && socket.readyState === 0) return;
415
+ if (socket && socket.readyState === 1) {
416
+ watchdog.probe(socket, lifecycle.getEpoch());
417
+ return;
418
+ }
419
+ scheduleReconnect(reason);
336
420
  }
337
421
 
338
- export function scheduleReconnect() {
339
- if (reconnectTimer) return;
340
- reconnectTimer = setTimeout(function () {
341
- reconnectTimer = null;
342
- // Check if auth is still valid before reconnecting
343
- fetch("/info").then(function (res) {
422
+ export function scheduleReconnect(reason) {
423
+ lifecycle.schedule(reason || "unknown", function (epoch) {
424
+ var authEpoch = epoch;
425
+ var controller = typeof AbortController === "function" ? new AbortController() : null;
426
+ var authToken = lifecycle.beginAuth(authEpoch, function () {
427
+ if (controller) controller.abort();
428
+ scheduleReconnect("auth_timeout");
429
+ }, function () {
430
+ if (controller) controller.abort();
431
+ });
432
+ if (!authToken) return;
433
+ fetch("/info", controller ? { signal: controller.signal } : undefined).then(function (res) {
434
+ if (!lifecycle.finishAuth(authEpoch, authToken)) return;
435
+ if (!lifecycle.canProceed(authEpoch)) return;
344
436
  if (res.status === 401) {
345
437
  location.reload();
346
438
  return;
347
439
  }
348
440
  connect();
349
441
  }).catch(function () {
350
- // Server still down, try connecting anyway
351
- connect();
442
+ var authFinished = lifecycle.finishAuth(authEpoch, authToken);
443
+ if (authFinished && lifecycle.canProceed(authEpoch)) connect();
352
444
  });
353
- }, reconnectDelay);
354
- reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
445
+ });
355
446
  }
@@ -4,7 +4,8 @@ import { handleIssuesMessageInContext as handleIssuesMessage } from './issues-co
4
4
  // All dependencies are direct imports; no context injection needed.
5
5
 
6
6
  import { store } from './store.js';
7
- import { acknowledgeMessage, replayPendingMessages } from './message-delivery.js';
7
+ import { acknowledgeMessage, activateDeliverySession, refreshDeliverySessionIdentity } from './message-delivery.js';
8
+ import { initMessageDeliveryUi } from './message-delivery-ui.js';
8
9
  import { getWs } from './ws-ref.js';
9
10
 
10
11
  // --- Leaf module imports ---
@@ -30,6 +31,7 @@ import { syncPaneTitles, maybeRestoreSplitGroup, openGroup } from './split-view.
30
31
  import { showPairDialog, handlePairCreated, handleSplitDelegation, showWorkerDelegationNotice, hideWorkerDelegationNotice } from './split-pair-ui.js';
31
32
  import { handleSessionActionMessage } from './session-actions.js';
32
33
  import { renderWorkerProposal, updateWorkerProposal } from './worker-proposal.js';
34
+ import { renderDriverContinuation, updateDriverContinuation, renderDriverContinuationContext } from './driver-continuation.js';
33
35
  import { handleInputSync, autoResize, builtinCommands, setScheduleBtnDisabled, sendShellResultToAgent, handleAcceptedOrdinaryMessage, handleScheduleMessageResult } from './input.js';
34
36
  import { startThinking, appendThinking, stopThinking, resetThinkingGroup, createToolItem, updateToolExecuting, updateToolResult, markAllToolsDone, closeToolGroup, removeToolFromGroup, resetToolState, getTools, getPlanContent, setPlanContent, renderPlanBanner, renderPlanCard, getTodoTools, handleTodoWrite, handleTaskCreate, handleTaskUpdate, applyDeadSessionTodoCompaction, isPlanFilePath, enableMainInput, addTurnMeta, updateSubagentActivity, addSubagentToolEntry, markSubagentDone, initSubagentStop, updateSubagentProgress, updateSubagentTaskStatus, renderAskUserQuestion, markAskUserAnswered, renderPermissionRequest, markPermissionCancelled, markPermissionResolved, renderElicitationRequest, markElicitationResolved, renderUserDialogRequest, markUserDialogResolved, updateThinkingTokens } from './tools.js';
35
37
  import { finishThinkingTurn, resumeThinkingAfterReplay } from './thinking-lifecycle.js';
@@ -60,6 +62,7 @@ import { handleShellCommandResult } from './shell-command.js';
60
62
  import { handleLoopRegistryUpdated, handleScheduleRunStarted, handleScheduleRunFinished, handleLoopScheduled, isSchedulerOpen, enterCraftingMode, exitCraftingMode, handleLoopRegistryFiles } from './scheduler.js';
61
63
  import { handleScheduledTaskMessage, handleScheduledTaskSessionSwitched } from './scheduled-tasks.js';
62
64
  import { renderGeneratedImage, renderImageGenerationProgress, clearImageGenerationProgress, clearAllImageGenerationProgress } from './generated-images.js';
65
+ import { clearPairResultStatus, handlePairResultStatus } from './pair-result-status.js';
63
66
 
64
67
  // --- App module imports ---
65
68
  import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
@@ -97,6 +100,7 @@ var messagesEl = document.getElementById("messages");
97
100
  var headerTitleEl = document.getElementById("header-title");
98
101
  var inputEl = document.getElementById("input");
99
102
  var connectOverlay = document.getElementById("connect-overlay");
103
+ initMessageDeliveryUi();
100
104
 
101
105
  export function processMessage(msg) {
102
106
  if (handleIssuesMessage(msg)) return;
@@ -240,6 +244,7 @@ export function processMessage(msg) {
240
244
  // Restore cached rich context usage BEFORE updateContextPanel runs
241
245
  if (msg.contextUsage && !msg.contextUsage.unavailable) store.set({ richContextUsage: msg.contextUsage });
242
246
  else markContextUnavailable();
247
+
243
248
  // Restore accurate context data from the last result in full history
244
249
  if (msg.lastUsage || msg.lastModelUsage) {
245
250
  accumulateContext(msg.lastCost, msg.lastUsage, msg.lastModelUsage, msg.lastStreamInputTokens);
@@ -732,6 +737,10 @@ export function processMessage(msg) {
732
737
  if (!msg.ok && msg.error) showToast(msg.error, "error");
733
738
  break;
734
739
 
740
+ case "split_worker_close_result":
741
+ if (!msg.ok && msg.error) showToast(msg.error, "error");
742
+ break;
743
+
735
744
  case "pair_session_options":
736
745
  showPairDialog(msg);
737
746
  break;
@@ -756,6 +765,19 @@ export function processMessage(msg) {
756
765
  updateWorkerProposal(msg);
757
766
  break;
758
767
 
768
+ case "driver_continuation_proposal":
769
+ renderDriverContinuation(msg);
770
+ break;
771
+
772
+ case "driver_continuation_update":
773
+ case "driver_continuation_result":
774
+ updateDriverContinuation(msg);
775
+ break;
776
+
777
+ case "driver_continuation_context":
778
+ renderDriverContinuationContext(msg);
779
+ break;
780
+
759
781
  case "split_delegation":
760
782
  handleSplitDelegation(msg);
761
783
  syncPaneTitles();
@@ -788,6 +810,10 @@ export function processMessage(msg) {
788
810
  handleRemoteSelection(msg);
789
811
  break;
790
812
 
813
+ case "pair_result_status":
814
+ handlePairResultStatus(msg);
815
+ break;
816
+
791
817
  case "session_io":
792
818
  blinkSessionDot(msg.id);
793
819
  break;
@@ -807,6 +833,7 @@ export function processMessage(msg) {
807
833
  break;
808
834
 
809
835
  case "session_switched":
836
+ clearPairResultStatus();
810
837
  syncAutonomousRunForSession(msg.autonomousRun || null);
811
838
  closeWhatsNewArticle();
812
839
  // Save draft from outgoing session
@@ -825,10 +852,10 @@ export function processMessage(msg) {
825
852
  var _effectiveTerminalId = (typeof msg.runtimeTerminalId === "number")
826
853
  ? msg.runtimeTerminalId
827
854
  : (typeof msg.terminalId === "number" ? msg.terminalId : null);
828
- store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || "", currentEffort: msg.effort || store.get('currentEffort'), vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionVendorBound: !!msg.vendor || !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || store.get('currentMode'), effectivePermissionMode: msg.effectivePermissionMode || null, permissionCapabilities: msg.permissionCapabilities || { auto: false, mcpOverride: false }, mcpPermissionModeOverrides: msg.mcpPermissionModeOverrides || {} });
855
+ store.set({ activeSessionId: msg.id, sessionOriginId: msg.sessionOriginId || null, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || "", currentEffort: msg.effort || store.get('currentEffort'), vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionVendorBound: !!msg.vendor || !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || store.get('currentMode'), effectivePermissionMode: msg.effectivePermissionMode || null, permissionCapabilities: msg.permissionCapabilities || { auto: false, mcpOverride: false }, mcpPermissionModeOverrides: msg.mcpPermissionModeOverrides || {} });
829
856
  handleScheduledTaskSessionSwitched(msg);
830
857
  requestLoopInterviewState();
831
- replayPendingMessages(msg.id);
858
+ activateDeliverySession(msg.id, msg.cliSessionId || null, msg.sessionOriginId || null);
832
859
  // TUI sessions swap the chat UI for an embedded xterm running
833
860
  // `claude` inside a real PTY. Mount or tear down before the rest of
834
861
  // the chat-side bookkeeping runs so we don't waste work on hidden DOM.
@@ -922,6 +949,7 @@ export function processMessage(msg) {
922
949
 
923
950
  case "session_id":
924
951
  store.set({ cliSessionId: msg.cliSessionId });
952
+ refreshDeliverySessionIdentity(msg.cliSessionId, store.get('sessionOriginId') || null);
925
953
  break;
926
954
 
927
955
  case "message_uuid":
@@ -0,0 +1,76 @@
1
+ function clean(value) {
2
+ return value === undefined || value === null ? "" : String(value);
3
+ }
4
+
5
+ export function continuationKey(message) {
6
+ if (!message) return "";
7
+ var projectSlug = clean(message.projectSlug);
8
+ var sourceOriginId = clean(message.sourceOriginId);
9
+ var proposalId = clean(message.proposalId);
10
+ if (!projectSlug || !sourceOriginId || !proposalId) return "";
11
+ return projectSlug + "\n" + sourceOriginId + "\n" + proposalId;
12
+ }
13
+
14
+ export function authoritativeState(message) {
15
+ return {
16
+ key: continuationKey(message),
17
+ projectSlug: clean(message.projectSlug),
18
+ sourceOriginId: clean(message.sourceOriginId),
19
+ proposalId: clean(message.proposalId),
20
+ sourceSessionId: Number(message.sourceSessionId),
21
+ status: message.status || "pending",
22
+ requestId: "",
23
+ requestKind: "",
24
+ inflight: false,
25
+ error: message.error || "",
26
+ };
27
+ }
28
+
29
+ export function beginRequest(state, kind, requestId, connected) {
30
+ if (!state || !state.key || state.inflight || connected !== true) return null;
31
+ if (kind === "decision" && state.status !== "pending") return null;
32
+ var next = Object.assign({}, state, {
33
+ inflight: true,
34
+ requestId: clean(requestId),
35
+ requestKind: kind,
36
+ error: "",
37
+ });
38
+ return next.requestId ? next : null;
39
+ }
40
+
41
+ export function applyResponse(state, message) {
42
+ if (!state || !message || continuationKey(message) !== state.key) return null;
43
+ if (message.requestId && clean(message.requestId) !== state.requestId) return null;
44
+ var next = Object.assign({}, state, { inflight: false, requestId: "", requestKind: "" });
45
+ var sourceSessionId = Number(message.sourceSessionId);
46
+ if (Number.isInteger(sourceSessionId) && sourceSessionId > 0) next.sourceSessionId = sourceSessionId;
47
+ if (message.status) next.status = message.status;
48
+ next.error = message.error || "";
49
+ return next;
50
+ }
51
+
52
+ export function failRequest(state, requestId, error) {
53
+ if (!state || !state.inflight || clean(requestId) !== state.requestId) return null;
54
+ return Object.assign({}, state, {
55
+ inflight: false,
56
+ requestId: "",
57
+ requestKind: "",
58
+ error: clean(error) || "The request could not be sent. Try again.",
59
+ });
60
+ }
61
+
62
+ export function sendContinuationPayload(ws, payload) {
63
+ if (!ws || ws.readyState !== 1) return { ok: false, error: "The connection is unavailable. Try again." };
64
+ try {
65
+ if (ws.send(JSON.stringify(payload)) === false) {
66
+ return { ok: false, error: "The request was not accepted by the connection. Try again." };
67
+ }
68
+ } catch (error) {
69
+ return { ok: false, error: "The request could not be sent. Try again." };
70
+ }
71
+ return { ok: true };
72
+ }
73
+
74
+ export function continuationCanAct(state, connected) {
75
+ return !!state && state.status === "pending" && !state.inflight && connected === true;
76
+ }