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.
- package/lib/driver-continuation-access.js +54 -0
- package/lib/driver-continuation-lease.js +52 -0
- package/lib/driver-continuation-pair-transfer.js +86 -0
- package/lib/driver-continuation-pair.js +82 -0
- package/lib/driver-continuation-record.js +40 -0
- package/lib/driver-continuation-startup.js +68 -0
- package/lib/driver-continuation-transaction.js +61 -0
- package/lib/driver-continuation-trigger.js +110 -0
- package/lib/project-connection.js +1 -1
- package/lib/project-driver-continuation.js +499 -0
- package/lib/project-message-delivery.js +20 -0
- package/lib/project-pair-lifecycle.js +73 -73
- package/lib/project-pair-replacement-state.js +5 -1
- package/lib/project-session-handoff.js +48 -55
- package/lib/project-user-message.js +21 -0
- package/lib/project-worker-proposal.js +1 -1
- package/lib/project.js +49 -2
- package/lib/public/css/driver-continuation.css +56 -0
- package/lib/public/css/overlays.css +44 -16
- package/lib/public/css/pane.css +9 -5
- package/lib/public/index.html +3 -3
- package/lib/public/modules/app-connection.js +169 -51
- package/lib/public/modules/app-messages.js +19 -3
- package/lib/public/modules/driver-continuation-state.js +76 -0
- package/lib/public/modules/driver-continuation.js +260 -0
- package/lib/public/modules/message-delivery-ui.js +63 -0
- package/lib/public/modules/message-delivery.js +163 -6
- package/lib/public/modules/permission-control.js +8 -3
- package/lib/public/modules/websocket-lifecycle.js +178 -0
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +77 -0
- package/lib/session-handoff-discovery.js +443 -0
- package/lib/session-handoff-mcp-server.js +32 -4
- package/lib/session-pair-prompts.js +5 -0
- package/lib/session-pair-turn-control.js +19 -3
- package/lib/session-split-group-anchors.js +44 -0
- package/lib/session-split-groups.js +209 -59
- package/lib/sessions.js +21 -3
- package/lib/ws-schema.js +6 -0
- 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:
|
|
527
|
-
gap:
|
|
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:
|
|
536
|
+
gap: 1px;
|
|
537
537
|
padding: 2px;
|
|
538
|
-
border: 1px solid var(--border,
|
|
539
|
-
border-radius:
|
|
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:
|
|
546
|
-
padding:
|
|
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
|
|
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:
|
|
568
|
+
flex: 0 1 auto;
|
|
559
569
|
min-width: 0;
|
|
560
|
-
max-width:
|
|
561
|
-
overflow
|
|
562
|
-
|
|
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:
|
|
599
|
+
.session-permission-control { column-gap: 4px; }
|
|
575
600
|
.session-permission-segmented button { padding-inline: 7px; }
|
|
576
601
|
.session-permission-status {
|
|
577
|
-
|
|
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 --- */
|
package/lib/public/css/pane.css
CHANGED
|
@@ -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:
|
|
373
|
+
height: 22px;
|
|
374
374
|
align-self: center;
|
|
375
375
|
margin-left: 0;
|
|
376
376
|
padding: 0;
|
|
377
377
|
gap: 4px;
|
|
378
|
-
font-size:
|
|
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
|
-
|
|
387
|
-
|
|
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:
|
|
401
|
+
font-size: 10px;
|
|
398
402
|
}
|
|
399
403
|
|
|
400
404
|
.split-pane-header .session-full-access-track {
|
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">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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
+
clearProbe();
|
|
423
|
+
stopHeartbeat();
|
|
424
|
+
lifecycle.cancel();
|
|
336
425
|
}
|
|
337
426
|
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
351
|
-
connect();
|
|
469
|
+
var authFinished = lifecycle.finishAuth(authEpoch, authToken);
|
|
470
|
+
if (authFinished && lifecycle.canProceed(authEpoch)) connect();
|
|
352
471
|
});
|
|
353
|
-
}
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|