clay-server 4.2.0 → 4.3.0-beta.2

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 (40) hide show
  1. package/lib/driver-continuation-access.js +54 -0
  2. package/lib/driver-continuation-lease.js +52 -0
  3. package/lib/driver-continuation-pair-transfer.js +86 -0
  4. package/lib/driver-continuation-pair.js +82 -0
  5. package/lib/driver-continuation-record.js +40 -0
  6. package/lib/driver-continuation-startup.js +68 -0
  7. package/lib/driver-continuation-transaction.js +61 -0
  8. package/lib/driver-continuation-trigger.js +110 -0
  9. package/lib/project-connection.js +1 -1
  10. package/lib/project-driver-continuation.js +499 -0
  11. package/lib/project-message-delivery.js +20 -0
  12. package/lib/project-pair-lifecycle.js +73 -73
  13. package/lib/project-pair-replacement-state.js +5 -1
  14. package/lib/project-session-handoff.js +48 -55
  15. package/lib/project-user-message.js +21 -0
  16. package/lib/project-worker-proposal.js +1 -1
  17. package/lib/project.js +49 -2
  18. package/lib/public/css/driver-continuation.css +56 -0
  19. package/lib/public/css/overlays.css +44 -16
  20. package/lib/public/css/pane.css +9 -5
  21. package/lib/public/index.html +3 -3
  22. package/lib/public/modules/app-connection.js +169 -51
  23. package/lib/public/modules/app-messages.js +19 -3
  24. package/lib/public/modules/driver-continuation-state.js +76 -0
  25. package/lib/public/modules/driver-continuation.js +260 -0
  26. package/lib/public/modules/message-delivery-ui.js +63 -0
  27. package/lib/public/modules/message-delivery.js +163 -6
  28. package/lib/public/modules/permission-control.js +8 -3
  29. package/lib/public/modules/websocket-lifecycle.js +178 -0
  30. package/lib/public/style.css +1 -0
  31. package/lib/sdk-bridge.js +77 -0
  32. package/lib/session-handoff-discovery.js +443 -0
  33. package/lib/session-handoff-mcp-server.js +32 -4
  34. package/lib/session-pair-prompts.js +5 -0
  35. package/lib/session-pair-turn-control.js +19 -3
  36. package/lib/session-split-group-anchors.js +44 -0
  37. package/lib/session-split-groups.js +209 -59
  38. package/lib/sessions.js +21 -3
  39. package/lib/ws-schema.js +6 -0
  40. package/package.json +1 -1
@@ -523,8 +523,8 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc
523
523
  display: inline-flex;
524
524
  align-items: center;
525
525
  flex: 0 1 auto;
526
- flex-wrap: wrap;
527
- gap: 7px;
526
+ flex-wrap: nowrap;
527
+ gap: 5px;
528
528
  max-width: 100%;
529
529
  min-width: 0;
530
530
  }
@@ -533,52 +533,80 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc
533
533
  display: inline-flex;
534
534
  align-items: center;
535
535
  flex: 0 0 auto;
536
- gap: 2px;
536
+ gap: 1px;
537
537
  padding: 2px;
538
- border: 1px solid var(--border, rgba(255,255,255,.18));
539
- border-radius: 999px;
540
- background: var(--surface-raised, rgba(0,0,0,.18));
538
+ border: 1px solid color-mix(in srgb, var(--border, #45484f) 88%, transparent);
539
+ border-radius: 10px;
540
+ background: color-mix(in srgb, var(--surface-raised, rgba(0,0,0,.18)) 86%, transparent);
541
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.04), 0 2px 8px rgba(0,0,0,.12);
541
542
  }
542
543
  .session-permission-segmented button {
544
+ position: relative;
543
545
  flex: 0 0 auto;
544
546
  border: 0;
545
- border-radius: 999px;
546
- padding: 3px 8px;
547
+ border-radius: 7px;
548
+ padding: 4px 9px;
547
549
  color: var(--text-muted, inherit);
548
550
  background: transparent;
549
551
  font: inherit;
550
552
  font-size: 11px;
553
+ line-height: 1;
551
554
  white-space: nowrap;
552
555
  cursor: pointer;
556
+ transition: color .15s ease, background .15s ease, box-shadow .15s ease;
553
557
  }
554
- .session-permission-segmented button.active { background: var(--accent, #6f8cff); color: #fff; }
558
+ .session-permission-segmented button:hover:not(:disabled) { color: var(--text, #fff); background: rgba(255,255,255,.07); }
559
+ .session-permission-segmented button:focus-visible { outline: 2px solid var(--accent, #6f8cff); outline-offset: 1px; z-index: 1; }
560
+ .session-permission-segmented button.active { background: var(--accent, #6f8cff); color: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.2); }
555
561
  .session-permission-segmented button:disabled { cursor: not-allowed; opacity: .5; }
556
562
  .session-permission-control.permission-pending .session-permission-segmented button:disabled { cursor: wait; }
563
+ .session-permission-segmented button.pending { color: #fff; }
564
+ .permission-spinner { display: inline-block; visibility: hidden; width: 10px; height: 10px; margin-left: 4px; vertical-align: -1px; border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; }
565
+ .session-permission-segmented button.pending .permission-spinner { visibility: visible; animation: permission-spin .7s linear infinite; }
566
+ @keyframes permission-spin { to { transform: rotate(360deg); } }
557
567
  .session-permission-status {
558
- flex: 1 1 118px;
568
+ flex: 0 1 auto;
559
569
  min-width: 0;
560
- max-width: 190px;
561
- overflow-wrap: anywhere;
562
- white-space: normal;
570
+ max-width: 180px;
571
+ overflow: hidden;
572
+ text-overflow: ellipsis;
573
+ white-space: nowrap;
574
+ padding: 3px 6px;
575
+ border: 1px solid transparent;
576
+ border-radius: 6px;
563
577
  color: var(--text-dimmer, var(--text-muted));
564
578
  font-size: 10px;
565
579
  line-height: 1.2;
566
580
  }
567
581
  .session-permission-status.hidden { display: none; }
582
+ .session-permission-control.permission-pending .session-permission-status:not(.hidden) {
583
+ position: absolute;
584
+ width: 1px;
585
+ height: 1px;
586
+ padding: 0;
587
+ margin: -1px;
588
+ overflow: hidden;
589
+ clip: rect(0, 0, 0, 0);
590
+ white-space: nowrap;
591
+ border: 0;
592
+ }
568
593
  .permission-runtime-fallback .session-permission-status { color: var(--warning, #d49a55); }
569
594
  .permission-globally-forced .session-permission-status { color: var(--warning, #d49a55); }
570
595
  .permission-label-short { display: none; }
571
596
  @media (max-width: 760px) {
572
597
  .permission-label-long { display: none; }
573
598
  .permission-label-short { display: inline; }
574
- .session-permission-control { column-gap: 5px; row-gap: 2px; }
599
+ .session-permission-control { column-gap: 4px; }
575
600
  .session-permission-segmented button { padding-inline: 7px; }
576
601
  .session-permission-status {
577
- flex-basis: 96px;
578
- max-width: 170px;
602
+ max-width: 132px;
579
603
  font-size: 9px;
580
604
  }
581
605
  }
606
+ @media (prefers-reduced-motion: reduce) {
607
+ .session-permission-segmented button { transition: none; }
608
+ .session-permission-segmented button.pending .permission-spinner { animation: none; opacity: .7; }
609
+ }
582
610
  #skip-perms-pill .lucide { width: 12px; height: 12px; flex-shrink: 0; }
583
611
 
584
612
  /* --- Confirm modal --- */
@@ -370,12 +370,12 @@ body.pane-mode #input-area {
370
370
  tie and keep the pill at its 22px title-bar size inside the 24px pane
371
371
  header. Scope under .split-pane-header to outrank them. */
372
372
  .split-pane-header .split-pane-full-access {
373
- height: auto;
373
+ height: 22px;
374
374
  align-self: center;
375
375
  margin-left: 0;
376
376
  padding: 0;
377
377
  gap: 4px;
378
- font-size: 8px;
378
+ font-size: 10px;
379
379
  letter-spacing: 0.02em;
380
380
  flex-wrap: nowrap;
381
381
  flex: 0 0 auto;
@@ -383,10 +383,14 @@ body.pane-mode #input-area {
383
383
  }
384
384
 
385
385
  .split-pane-header .session-permission-segmented button {
386
- padding: 1px 5px;
387
- font-size: 8px;
386
+ min-height: 18px;
387
+ padding: 3px 6px;
388
+ font-size: 10px;
388
389
  }
389
390
 
391
+ .split-pane-header .permission-label-long { display: none; }
392
+ .split-pane-header .permission-label-short { display: inline; }
393
+
390
394
  .split-pane-header .session-permission-status {
391
395
  flex: 0 0 auto;
392
396
  min-width: 40px;
@@ -394,7 +398,7 @@ body.pane-mode #input-area {
394
398
  overflow: hidden;
395
399
  text-overflow: ellipsis;
396
400
  white-space: nowrap;
397
- font-size: 8px;
401
+ font-size: 10px;
398
402
  }
399
403
 
400
404
  .split-pane-header .session-full-access-track {
@@ -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"><span class="permission-label">Ask</span><span class="permission-spinner" aria-hidden="true"></span></button>
414
+ <button type="button" data-permission-mode="auto"><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-long permission-label">Skip permissions</span><span class="permission-label-short permission-label" aria-hidden="true">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,8 @@ 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';
25
26
 
26
- var reconnectTimer = null;
27
- var reconnectDelay = 1000;
28
- var connectTimeoutId = null;
29
27
  var connectOverlay = null;
30
28
  var hasConnectedOnce = false;
31
29
 
@@ -46,7 +44,22 @@ var overlayGraceTimer = null;
46
44
  var HEARTBEAT_MS = 25000;
47
45
  var heartbeatTimer = null;
48
46
  var heartbeatDeadlineTimer = null;
47
+ var heartbeatAwaitingSocket = null;
49
48
  var disconnectedAt = 0;
49
+ var attemptStartedAt = 0;
50
+ var attemptSocket = null;
51
+ var probeTimer = null;
52
+ var probeSocket = null;
53
+ var probeEpoch = 0;
54
+ var probeStartedAt = 0;
55
+ var lifecycle = createWebSocketLifecycle({
56
+ handshakeTimeoutMs: 12000,
57
+ onHandshakeTimeout: function (epoch) {
58
+ if (lifecycle.current(epoch) && attemptSocket) {
59
+ forceReplaceSocket("handshake_timeout", 0, epoch);
60
+ }
61
+ }
62
+ });
50
63
 
51
64
  function clearOverlayGrace() {
52
65
  if (overlayGraceTimer) {
@@ -64,6 +77,45 @@ function showOverlayNow() {
64
77
  connectOverlay.classList.remove("hidden");
65
78
  }
66
79
 
80
+ function recordConnectionDiagnostic(reason, code, latency) {
81
+ 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 };
82
+ if (!safeReasons[reason]) reason = "unknown";
83
+ console.warn("[clay] WebSocket diagnostic", {
84
+ reason: reason,
85
+ code: typeof code === "number" ? code : 0,
86
+ retry: lifecycle.getRetryAttempt(),
87
+ latencyMs: typeof latency === "number" ? Math.max(0, Math.min(latency, 600000)) : null
88
+ });
89
+ }
90
+
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
+ if (!lifecycle.current(epoch)) return;
101
+ var socket = getWs();
102
+ var latency = probeStartedAt ? Date.now() - probeStartedAt : null;
103
+ 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) {}
111
+ }
112
+ setWs(null);
113
+ recordConnectionDiagnostic(reason, code, latency);
114
+ setStatus("disconnected");
115
+ lifecycle.invalidate(epoch);
116
+ scheduleReconnect(reason);
117
+ }
118
+
67
119
  export function stopHeartbeat() {
68
120
  if (heartbeatTimer) {
69
121
  clearInterval(heartbeatTimer);
@@ -73,6 +125,23 @@ export function stopHeartbeat() {
73
125
  clearTimeout(heartbeatDeadlineTimer);
74
126
  heartbeatDeadlineTimer = null;
75
127
  }
128
+ heartbeatAwaitingSocket = null;
129
+ }
130
+
131
+ function suspendSocketForOffline() {
132
+ clearProbe();
133
+ stopHeartbeat();
134
+ var socket = getWs();
135
+ attemptSocket = null;
136
+ if (socket) {
137
+ socket.onopen = null;
138
+ socket.onmessage = null;
139
+ socket.onerror = null;
140
+ socket.onclose = null;
141
+ try { socket.close(); } catch (e) {}
142
+ }
143
+ setWs(null);
144
+ if (socket || store.get('connected')) setStatus("disconnected");
76
145
  }
77
146
 
78
147
  // Started once per live socket. The socket it was started for is captured, so
@@ -80,28 +149,54 @@ export function stopHeartbeat() {
80
149
  // connection. The pong the server sends back keeps a proxy's upstream read
81
150
  // timeout from expiring. A missing pong also identifies a half-open socket,
82
151
  // which browsers can otherwise leave OPEN while silently losing sends.
83
- export function startHeartbeat(socket) {
152
+ export function startHeartbeat(socket, attemptEpoch) {
84
153
  stopHeartbeat();
85
154
  if (!socket) return;
86
155
  heartbeatTimer = setInterval(function () {
87
- if (!socket || socket.readyState !== 1 || getWs() !== socket) {
156
+ if (!socket || socket.readyState !== 1 || getWs() !== socket || !lifecycle.current(attemptEpoch)) {
88
157
  stopHeartbeat();
89
158
  return;
90
159
  }
91
- try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {}
160
+ try { socket.send(JSON.stringify({ type: "ping" })); } catch (e) {
161
+ forceReplaceSocket("heartbeat_error", 0, attemptEpoch);
162
+ return;
163
+ }
164
+ heartbeatAwaitingSocket = socket;
92
165
  if (heartbeatDeadlineTimer) clearTimeout(heartbeatDeadlineTimer);
93
166
  heartbeatDeadlineTimer = setTimeout(function () {
94
167
  heartbeatDeadlineTimer = null;
95
- if (getWs() === socket && socket.readyState === 1) connect();
168
+ if (getWs() === socket && socket.readyState === 1 && lifecycle.current(attemptEpoch)) {
169
+ forceReplaceSocket("heartbeat_timeout", 0, attemptEpoch);
170
+ }
96
171
  }, 10000);
97
172
  }, HEARTBEAT_MS);
98
173
  }
99
174
 
100
175
  export function initConnection() {
101
176
  connectOverlay = document.getElementById("connect-overlay");
177
+ if (typeof navigator !== "undefined" && navigator.onLine === false) lifecycle.setOffline(true);
102
178
  window.addEventListener("clay-message-delivery-timeout", function (event) {
103
179
  var socket = event.detail && event.detail.socket;
104
- if (!socket || getWs() === socket) connect();
180
+ recordConnectionDiagnostic("ack-timeout", 0, null);
181
+ if (!socket || getWs() === socket) probeReconnect("ack-timeout");
182
+ });
183
+ window.addEventListener("offline", function () {
184
+ lifecycle.setOffline(true);
185
+ suspendSocketForOffline();
186
+ });
187
+ window.addEventListener("online", function () {
188
+ lifecycle.wake(function () {
189
+ var socket = getWs();
190
+ if (socket && socket.readyState === 1) startHeartbeat(socket, lifecycle.getEpoch());
191
+ probeReconnect("online");
192
+ });
193
+ });
194
+ 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
+ }
105
200
  });
106
201
 
107
202
  // --- Reactive UI sync for connected/processing state ---
@@ -224,6 +319,9 @@ function onConnected() {
224
319
  }
225
320
 
226
321
  export function connect() {
322
+ if (lifecycle.isOffline()) return;
323
+ var attemptEpoch = lifecycle.beginAttempt();
324
+ attemptStartedAt = Date.now();
227
325
  var ws = getWs();
228
326
  // Tear down the previous socket's heartbeat before a new socket exists, so
229
327
  // two sockets can never both be pinging.
@@ -236,37 +334,24 @@ export function connect() {
236
334
  ws.onclose = null;
237
335
  ws.close();
238
336
  }
239
- if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
240
-
241
337
  var protocol = location.protocol === "https:" ? "wss:" : "ws:";
242
338
  var socketPath = store.get('wsPath');
243
339
  if (store.get('socketPath') && store.get('socketPath') !== socketPath) clearProjectSplitState();
244
340
  store.set({ socketPath: socketPath, activeProjectSlug: null, sessionActivatedProjectSlug: null, sessionListProjectSlug: null, splitGroupsProjectSlug: null });
245
341
  var newWs = new WebSocket(protocol + "//" + location.host + socketPath);
246
342
  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);
343
+ attemptSocket = newWs;
258
344
 
259
345
  newWs.onopen = function () {
260
- if (getWs() !== newWs) return;
261
- if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
346
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
347
+ if (!lifecycle.markOpen(attemptEpoch)) return;
262
348
  if (hasConnectedOnce && disconnectedAt) {
263
349
  console.log("[clay] WebSocket reconnected after " + (Date.now() - disconnectedAt) + "ms");
264
350
  }
265
351
  disconnectedAt = 0;
266
352
  setStatus("connected");
267
- startHeartbeat(newWs);
268
- reconnectDelay = 1000;
269
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
353
+ startHeartbeat(newWs, attemptEpoch);
354
+ lifecycle.clearRetry();
270
355
 
271
356
  // A pane pin is one-shot per WebSocket, not per page lifetime. The server
272
357
  // intentionally does not restore pane presence after a daemon restart.
@@ -292,64 +377,97 @@ export function connect() {
292
377
  };
293
378
 
294
379
  newWs.onclose = function (e) {
295
- if (getWs() !== newWs) return;
296
- if (connectTimeoutId) { clearTimeout(connectTimeoutId); connectTimeoutId = null; }
380
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
297
381
  stopHeartbeat();
382
+ clearProbe();
298
383
  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
- }
384
+ recordConnectionDiagnostic("close", e && e.code, attemptStartedAt ? Date.now() - attemptStartedAt : null);
305
385
  closeDmUserPicker();
306
386
  store.set({ activeProjectSlug: null, sessionActivatedProjectSlug: null });
307
387
  setStatus("disconnected");
308
388
  setActivity(null);
309
- scheduleReconnect();
389
+ lifecycle.invalidate(attemptEpoch);
390
+ scheduleReconnect("close");
310
391
  };
311
392
 
312
- newWs.onerror = function () {};
393
+ newWs.onerror = function () {
394
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
395
+ recordConnectionDiagnostic("error", 0, null);
396
+ forceReplaceSocket("error", 0, attemptEpoch);
397
+ };
313
398
 
314
399
  newWs.onmessage = function (event) {
315
- if (getWs() !== newWs) return;
400
+ if (getWs() !== newWs || !lifecycle.current(attemptEpoch)) return;
316
401
  // Backup: if we're receiving messages, we're connected
317
402
  if (!store.get('connected')) {
318
403
  setStatus("connected");
319
- reconnectDelay = 1000;
320
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
404
+ lifecycle.clearRetry();
321
405
  }
322
406
 
323
407
  blinkIO();
324
408
  var msg;
325
409
  try { msg = JSON.parse(event.data); } catch (e) { return; }
326
- if (msg.type === "pong" && heartbeatDeadlineTimer) {
410
+ lifecycle.markLive(attemptEpoch);
411
+ if (msg.type === "pong" && heartbeatDeadlineTimer && heartbeatAwaitingSocket === newWs) {
327
412
  clearTimeout(heartbeatDeadlineTimer);
328
413
  heartbeatDeadlineTimer = null;
414
+ heartbeatAwaitingSocket = null;
329
415
  }
416
+ if (msg.type === "pong" && probeSocket === newWs && lifecycle.current(probeEpoch)) clearProbe();
330
417
  processMessage(msg);
331
418
  };
332
419
  }
333
420
 
334
421
  export function cancelReconnect() {
335
- if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
422
+ clearProbe();
423
+ stopHeartbeat();
424
+ lifecycle.cancel();
336
425
  }
337
426
 
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) {
427
+ function probeReconnect(reason) {
428
+ if (lifecycle.isOffline() || !lifecycle.current(lifecycle.getEpoch())) return;
429
+ var socket = getWs();
430
+ if (socket && socket.readyState === 0) return;
431
+ 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);
444
+ return;
445
+ }
446
+ scheduleReconnect(reason);
447
+ }
448
+
449
+ export function scheduleReconnect(reason) {
450
+ lifecycle.schedule(reason || "unknown", function (epoch) {
451
+ var authEpoch = epoch;
452
+ var controller = typeof AbortController === "function" ? new AbortController() : null;
453
+ var authToken = lifecycle.beginAuth(authEpoch, function () {
454
+ if (controller) controller.abort();
455
+ scheduleReconnect("auth_timeout");
456
+ }, function () {
457
+ if (controller) controller.abort();
458
+ });
459
+ if (!authToken) return;
460
+ fetch("/info", controller ? { signal: controller.signal } : undefined).then(function (res) {
461
+ if (!lifecycle.finishAuth(authEpoch, authToken)) return;
462
+ if (!lifecycle.canProceed(authEpoch)) return;
344
463
  if (res.status === 401) {
345
464
  location.reload();
346
465
  return;
347
466
  }
348
467
  connect();
349
468
  }).catch(function () {
350
- // Server still down, try connecting anyway
351
- connect();
469
+ var authFinished = lifecycle.finishAuth(authEpoch, authToken);
470
+ if (authFinished && lifecycle.canProceed(authEpoch)) connect();
352
471
  });
353
- }, reconnectDelay);
354
- reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
472
+ });
355
473
  }
@@ -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';
@@ -97,6 +99,7 @@ var messagesEl = document.getElementById("messages");
97
99
  var headerTitleEl = document.getElementById("header-title");
98
100
  var inputEl = document.getElementById("input");
99
101
  var connectOverlay = document.getElementById("connect-overlay");
102
+ initMessageDeliveryUi();
100
103
 
101
104
  export function processMessage(msg) {
102
105
  if (handleIssuesMessage(msg)) return;
@@ -756,6 +759,19 @@ export function processMessage(msg) {
756
759
  updateWorkerProposal(msg);
757
760
  break;
758
761
 
762
+ case "driver_continuation_proposal":
763
+ renderDriverContinuation(msg);
764
+ break;
765
+
766
+ case "driver_continuation_update":
767
+ case "driver_continuation_result":
768
+ updateDriverContinuation(msg);
769
+ break;
770
+
771
+ case "driver_continuation_context":
772
+ renderDriverContinuationContext(msg);
773
+ break;
774
+
759
775
  case "split_delegation":
760
776
  handleSplitDelegation(msg);
761
777
  syncPaneTitles();
@@ -825,10 +841,9 @@ export function processMessage(msg) {
825
841
  var _effectiveTerminalId = (typeof msg.runtimeTerminalId === "number")
826
842
  ? msg.runtimeTerminalId
827
843
  : (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 || {} });
829
844
  handleScheduledTaskSessionSwitched(msg);
830
845
  requestLoopInterviewState();
831
- replayPendingMessages(msg.id);
846
+ activateDeliverySession(msg.id, msg.cliSessionId || null, msg.sessionOriginId || null);
832
847
  // TUI sessions swap the chat UI for an embedded xterm running
833
848
  // `claude` inside a real PTY. Mount or tear down before the rest of
834
849
  // the chat-side bookkeeping runs so we don't waste work on hidden DOM.
@@ -922,6 +937,7 @@ export function processMessage(msg) {
922
937
 
923
938
  case "session_id":
924
939
  store.set({ cliSessionId: msg.cliSessionId });
940
+ refreshDeliverySessionIdentity(msg.cliSessionId, store.get('sessionOriginId') || null);
925
941
  break;
926
942
 
927
943
  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
+ }