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.
- package/lib/driver-continuation-pair.js +5 -1
- package/lib/multi-worker-feature.js +11 -0
- package/lib/project-connection.js +3 -1
- package/lib/project-message-delivery.js +20 -0
- package/lib/project-pair-autonomous-stop.js +14 -8
- package/lib/project-pair-close-transaction.js +59 -0
- package/lib/project-pair-global-stop.js +18 -0
- package/lib/project-pair-lifecycle-status.js +34 -0
- package/lib/project-pair-lifecycle.js +58 -76
- package/lib/project-pair-message.js +1 -1
- package/lib/project-pair-owned-stop.js +7 -4
- package/lib/project-pair-result-capture.js +116 -0
- package/lib/project-pair-result-delivery.js +140 -0
- package/lib/project-pair-result-outbox.js +400 -0
- package/lib/project-pair-result-pipeline.js +16 -0
- package/lib/project-pair-result-recovery.js +229 -0
- package/lib/project-pair-structural-close.js +20 -0
- package/lib/project-pair-task-control.js +45 -18
- package/lib/project-pair-worker-close.js +68 -0
- package/lib/project-session-pair-prompt.js +26 -0
- package/lib/project-session-pair.js +87 -106
- package/lib/project-sessions.js +4 -0
- package/lib/project-user-message.js +7 -0
- package/lib/project-worker-creation-proposal.js +176 -0
- package/lib/project-worker-permission.js +7 -8
- package/lib/project-worker-proposal-runner.js +45 -0
- package/lib/project-worker-proposal.js +79 -115
- package/lib/project.js +38 -1
- package/lib/public/app.js +1 -0
- package/lib/public/css/messages.css +12 -0
- package/lib/public/css/mobile-nav.css +29 -14
- package/lib/public/css/overlays.css +22 -21
- package/lib/public/css/pane.css +64 -14
- package/lib/public/css/sidebar.css +62 -7
- package/lib/public/index.html +3 -3
- package/lib/public/modules/app-connection.js +165 -74
- package/lib/public/modules/app-messages.js +17 -3
- package/lib/public/modules/message-delivery-ui.js +63 -0
- package/lib/public/modules/message-delivery.js +163 -6
- package/lib/public/modules/pair-result-status.js +87 -0
- package/lib/public/modules/permission-control.js +5 -5
- package/lib/public/modules/project-activation.js +10 -7
- package/lib/public/modules/session-creation-cta.js +46 -0
- package/lib/public/modules/sidebar-mobile.js +42 -30
- package/lib/public/modules/sidebar-sessions.js +77 -52
- package/lib/public/modules/split-group-helpers.js +74 -2
- package/lib/public/modules/split-pair-ui.js +28 -6
- package/lib/public/modules/split-pane-reconciler.js +28 -0
- package/lib/public/modules/split-pane-renderer.js +107 -0
- package/lib/public/modules/split-view.js +58 -188
- package/lib/public/modules/websocket-lifecycle.js +178 -0
- package/lib/public/modules/websocket-watchdog.js +126 -0
- package/lib/public/modules/worker-pane-lock.js +13 -3
- package/lib/server.js +1 -0
- package/lib/session-pair-factory.js +66 -0
- package/lib/session-pair-mcp-server.js +10 -5
- package/lib/session-pair-prompts.js +41 -25
- package/lib/session-pair-target.js +42 -0
- package/lib/session-pair-turn-control.js +11 -6
- package/lib/session-split-group-anchors.js +89 -7
- package/lib/session-split-group-persistence.js +136 -0
- package/lib/session-split-group-roles.js +164 -0
- package/lib/session-split-group-v2-store.js +146 -0
- package/lib/session-split-groups.js +109 -99
- package/lib/sessions.js +70 -21
- package/lib/ws-schema.js +2 -0
- 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
|
-
/*
|
|
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
|
|
539
|
-
border-radius:
|
|
540
|
-
background: color-mix(in srgb, var(--
|
|
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
|
|
544
|
+
flex: 0 0 48px;
|
|
545
|
+
width: 48px;
|
|
546
|
+
height: 22px;
|
|
546
547
|
border: 0;
|
|
547
548
|
border-radius: 7px;
|
|
548
|
-
padding: 4px
|
|
549
|
-
color: var(--text-
|
|
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
|
|
559
|
-
.session-permission-segmented button:focus-visible { outline: 2px solid var(--accent
|
|
560
|
-
.session-permission-segmented button.active { background: var(--
|
|
561
|
-
.session-permission-segmented button
|
|
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:
|
|
564
|
-
.permission-
|
|
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;
|
package/lib/public/css/pane.css
CHANGED
|
@@ -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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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:
|
|
398
|
+
height: 24px;
|
|
374
399
|
align-self: center;
|
|
375
400
|
margin-left: 0;
|
|
376
401
|
padding: 0;
|
|
377
402
|
gap: 4px;
|
|
378
|
-
font-size:
|
|
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:
|
|
387
|
-
|
|
388
|
-
font-size:
|
|
410
|
+
min-height: 20px;
|
|
411
|
+
height: 20px;
|
|
412
|
+
font-size: 11px;
|
|
389
413
|
}
|
|
390
414
|
|
|
391
|
-
.split-pane-header .permission-
|
|
392
|
-
|
|
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
|
-
|
|
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:
|
|
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-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
-
|
|
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 {
|
package/lib/public/index.html
CHANGED
|
@@ -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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
351
|
-
connect();
|
|
442
|
+
var authFinished = lifecycle.finishAuth(authEpoch, authToken);
|
|
443
|
+
if (authFinished && lifecycle.canProceed(authEpoch)) connect();
|
|
352
444
|
});
|
|
353
|
-
}
|
|
354
|
-
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
|
|
445
|
+
});
|
|
355
446
|
}
|