clay-server 4.3.0-beta.1 → 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 (67) 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 +3 -1
  4. package/lib/project-message-delivery.js +20 -0
  5. package/lib/project-pair-autonomous-stop.js +14 -8
  6. package/lib/project-pair-close-transaction.js +59 -0
  7. package/lib/project-pair-global-stop.js +18 -0
  8. package/lib/project-pair-lifecycle-status.js +34 -0
  9. package/lib/project-pair-lifecycle.js +58 -76
  10. package/lib/project-pair-message.js +1 -1
  11. package/lib/project-pair-owned-stop.js +7 -4
  12. package/lib/project-pair-result-capture.js +116 -0
  13. package/lib/project-pair-result-delivery.js +140 -0
  14. package/lib/project-pair-result-outbox.js +400 -0
  15. package/lib/project-pair-result-pipeline.js +16 -0
  16. package/lib/project-pair-result-recovery.js +229 -0
  17. package/lib/project-pair-structural-close.js +20 -0
  18. package/lib/project-pair-task-control.js +45 -18
  19. package/lib/project-pair-worker-close.js +68 -0
  20. package/lib/project-session-pair-prompt.js +26 -0
  21. package/lib/project-session-pair.js +87 -106
  22. package/lib/project-sessions.js +4 -0
  23. package/lib/project-user-message.js +7 -0
  24. package/lib/project-worker-creation-proposal.js +176 -0
  25. package/lib/project-worker-permission.js +7 -8
  26. package/lib/project-worker-proposal-runner.js +45 -0
  27. package/lib/project-worker-proposal.js +79 -115
  28. package/lib/project.js +38 -1
  29. package/lib/public/app.js +1 -0
  30. package/lib/public/css/messages.css +12 -0
  31. package/lib/public/css/mobile-nav.css +29 -14
  32. package/lib/public/css/overlays.css +22 -21
  33. package/lib/public/css/pane.css +64 -14
  34. package/lib/public/css/sidebar.css +62 -7
  35. package/lib/public/index.html +3 -3
  36. package/lib/public/modules/app-connection.js +165 -74
  37. package/lib/public/modules/app-messages.js +17 -3
  38. package/lib/public/modules/message-delivery-ui.js +63 -0
  39. package/lib/public/modules/message-delivery.js +163 -6
  40. package/lib/public/modules/pair-result-status.js +87 -0
  41. package/lib/public/modules/permission-control.js +5 -5
  42. package/lib/public/modules/project-activation.js +10 -7
  43. package/lib/public/modules/session-creation-cta.js +46 -0
  44. package/lib/public/modules/sidebar-mobile.js +42 -30
  45. package/lib/public/modules/sidebar-sessions.js +77 -52
  46. package/lib/public/modules/split-group-helpers.js +74 -2
  47. package/lib/public/modules/split-pair-ui.js +28 -6
  48. package/lib/public/modules/split-pane-reconciler.js +28 -0
  49. package/lib/public/modules/split-pane-renderer.js +107 -0
  50. package/lib/public/modules/split-view.js +58 -188
  51. package/lib/public/modules/websocket-lifecycle.js +178 -0
  52. package/lib/public/modules/websocket-watchdog.js +126 -0
  53. package/lib/public/modules/worker-pane-lock.js +13 -3
  54. package/lib/server.js +1 -0
  55. package/lib/session-pair-factory.js +66 -0
  56. package/lib/session-pair-mcp-server.js +10 -5
  57. package/lib/session-pair-prompts.js +41 -25
  58. package/lib/session-pair-target.js +42 -0
  59. package/lib/session-pair-turn-control.js +11 -6
  60. package/lib/session-split-group-anchors.js +89 -7
  61. package/lib/session-split-group-persistence.js +136 -0
  62. package/lib/session-split-group-roles.js +164 -0
  63. package/lib/session-split-group-v2-store.js +146 -0
  64. package/lib/session-split-groups.js +109 -99
  65. package/lib/sessions.js +70 -21
  66. package/lib/ws-schema.js +2 -0
  67. package/package.json +1 -1
@@ -518,7 +518,7 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc
518
518
  .popover-copy:hover { color: var(--text); border-color: var(--text-dimmer); }
519
519
  .popover-copy.copied { color: var(--success); border-color: var(--success); }
520
520
 
521
- /* Skip permissions pill */
521
+ /* Quiet native toolbar control for session permission mode. */
522
522
  .session-permission-control {
523
523
  display: inline-flex;
524
524
  align-items: center;
@@ -535,35 +535,40 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc
535
535
  flex: 0 0 auto;
536
536
  gap: 1px;
537
537
  padding: 2px;
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);
538
+ border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
539
+ border-radius: 8px;
540
+ background: color-mix(in srgb, var(--bg-alt) 72%, transparent);
542
541
  }
543
542
  .session-permission-segmented button {
544
543
  position: relative;
545
- flex: 0 0 auto;
544
+ flex: 0 0 48px;
545
+ width: 48px;
546
+ height: 22px;
546
547
  border: 0;
547
548
  border-radius: 7px;
548
- padding: 4px 9px;
549
- color: var(--text-muted, inherit);
549
+ padding: 3px 4px;
550
+ color: var(--text-secondary);
550
551
  background: transparent;
551
552
  font: inherit;
553
+ font-weight: 500;
552
554
  font-size: 11px;
553
555
  line-height: 1;
554
556
  white-space: nowrap;
555
557
  cursor: pointer;
556
- transition: color .15s ease, background .15s ease, box-shadow .15s ease;
557
- }
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); }
561
- .session-permission-segmented button:disabled { cursor: not-allowed; opacity: .5; }
558
+ transition: color .15s ease, background .15s ease, box-shadow .15s ease, border-color .15s ease;
559
+ }
560
+ .session-permission-segmented button:hover:not(:disabled) { color: var(--text); background: color-mix(in srgb, var(--bg) 76%, transparent); }
561
+ .session-permission-segmented button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; z-index: 1; }
562
+ .session-permission-segmented button.active { border: 1px solid var(--border); background: var(--input-bg); color: var(--text); font-weight: var(--font-weight-heading, 600); box-shadow: 0 1px 2px rgba(var(--shadow-rgb), .12); }
563
+ :root.light-theme .session-permission-segmented button.active { background: var(--bg); }
564
+ .session-permission-segmented button:disabled { cursor: not-allowed; }
565
+ .session-permission-control:not(.permission-pending) .session-permission-segmented button:disabled { color: var(--text-muted); opacity: .65; }
562
566
  .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%; }
567
+ .session-permission-segmented button.pending { color: var(--text); background: color-mix(in srgb, var(--bg) 42%, transparent); }
568
+ .session-permission-segmented button.pending .permission-label { visibility: hidden; }
569
+ .permission-spinner { position: absolute; inset: 50% auto auto 50%; display: block; visibility: hidden; width: 10px; height: 10px; transform: translate(-50%, -50%); border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; }
565
570
  .session-permission-segmented button.pending .permission-spinner { visibility: visible; animation: permission-spin .7s linear infinite; }
566
- @keyframes permission-spin { to { transform: rotate(360deg); } }
571
+ @keyframes permission-spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
567
572
  .session-permission-status {
568
573
  flex: 0 1 auto;
569
574
  min-width: 0;
@@ -592,12 +597,8 @@ button.top-bar-pill.pill-accent:hover { background: color-mix(in srgb, var(--acc
592
597
  }
593
598
  .permission-runtime-fallback .session-permission-status { color: var(--warning, #d49a55); }
594
599
  .permission-globally-forced .session-permission-status { color: var(--warning, #d49a55); }
595
- .permission-label-short { display: none; }
596
600
  @media (max-width: 760px) {
597
- .permission-label-long { display: none; }
598
- .permission-label-short { display: inline; }
599
601
  .session-permission-control { column-gap: 4px; }
600
- .session-permission-segmented button { padding-inline: 7px; }
601
602
  .session-permission-status {
602
603
  max-width: 132px;
603
604
  font-size: 9px;
@@ -73,6 +73,28 @@ body.pane-mode #input-area {
73
73
  display: flex;
74
74
  }
75
75
 
76
+ /* A configured two-Worker group is a deliberate workspace hierarchy: the
77
+ Driver gets the broad working canvas, while both Workers remain visible as
78
+ equal-height execution surfaces on the right. Legacy/ad-hoc two-pane
79
+ splits keep the ordinary equal columns below. */
80
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) {
81
+ display: grid;
82
+ grid-template-columns: minmax(0, 3fr) minmax(260px, 2fr);
83
+ grid-template-rows: repeat(2, minmax(0, 1fr));
84
+ gap: 1px;
85
+ background: var(--border-subtle);
86
+ }
87
+
88
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-driver {
89
+ grid-row: 1 / span 2;
90
+ grid-column: 1;
91
+ }
92
+
93
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-worker {
94
+ grid-column: 2;
95
+ grid-row: var(--split-worker-row, auto);
96
+ }
97
+
76
98
  .split-pane {
77
99
  flex: 1;
78
100
  display: flex;
@@ -365,31 +387,35 @@ body.pane-mode #input-area {
365
387
  line-height: 1;
366
388
  }
367
389
 
368
- /* Header-scoped (two-class) selectors: pane.css loads BEFORE menus.css in
369
- style.css, so the base .session-full-access rules would win a specificity
370
- tie and keep the pill at its 22px title-bar size inside the 24px pane
371
- header. Scope under .split-pane-header to outrank them. */
390
+ /* Header-scoped selectors keep the compact permission control clear of the
391
+ title and close affordance while preserving the shared header rhythm. */
392
+ .split-pane-header {
393
+ height: 28px;
394
+ min-height: 28px;
395
+ }
396
+
372
397
  .split-pane-header .split-pane-full-access {
373
- height: 22px;
398
+ height: 24px;
374
399
  align-self: center;
375
400
  margin-left: 0;
376
401
  padding: 0;
377
402
  gap: 4px;
378
- font-size: 10px;
403
+ font-size: 11px;
379
404
  letter-spacing: 0.02em;
380
405
  flex-wrap: nowrap;
381
406
  flex: 0 0 auto;
382
- overflow: hidden;
383
407
  }
384
408
 
385
409
  .split-pane-header .session-permission-segmented button {
386
- min-height: 18px;
387
- padding: 3px 6px;
388
- font-size: 10px;
410
+ min-height: 20px;
411
+ height: 20px;
412
+ font-size: 11px;
389
413
  }
390
414
 
391
- .split-pane-header .permission-label-long { display: none; }
392
- .split-pane-header .permission-label-short { display: inline; }
415
+ .split-pane-header .session-permission-segmented {
416
+ height: 24px;
417
+ padding: 1px;
418
+ }
393
419
 
394
420
  .split-pane-header .session-permission-status {
395
421
  flex: 0 0 auto;
@@ -437,6 +463,11 @@ body.pane-mode #input-area {
437
463
  color: var(--text);
438
464
  }
439
465
 
466
+ .split-pane-close:disabled {
467
+ opacity: 0.42;
468
+ cursor: not-allowed;
469
+ }
470
+
440
471
  .split-pane-close svg {
441
472
  width: 12px;
442
473
  height: 12px;
@@ -474,6 +505,25 @@ body.pane-mode #input-area {
474
505
  --split-worker-height: calc(30% - 4.8px);
475
506
  }
476
507
 
508
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) {
509
+ display: flex;
510
+ flex-direction: column;
511
+ gap: var(--split-pane-gap);
512
+ background: transparent;
513
+ }
514
+
515
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-driver,
516
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-worker {
517
+ grid-row: auto;
518
+ grid-column: auto;
519
+ }
520
+
521
+ #split-host:has(.split-pane-role-worker:nth-of-type(3)) .split-pane-role-worker {
522
+ flex: 0 1 24%;
523
+ min-height: 104px;
524
+ order: -1;
525
+ }
526
+
477
527
  #split-host.visible {
478
528
  flex-direction: column;
479
529
  gap: var(--split-pane-gap);
@@ -491,11 +541,11 @@ body.pane-mode #input-area {
491
541
  }
492
542
 
493
543
  .split-pane-role-worker {
494
- order: -1;
544
+ order: var(--split-pane-order, -1);
495
545
  }
496
546
 
497
547
  .split-pane-role-driver {
498
- order: 0;
548
+ order: var(--split-pane-order, 0);
499
549
  }
500
550
 
501
551
  /* A configured Worker is an execution summary; the Driver keeps the
@@ -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"><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>
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
  }