clay-server 4.3.0-beta.1 → 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/project-connection.js +1 -1
- package/lib/project-message-delivery.js +20 -0
- package/lib/project-user-message.js +7 -0
- package/lib/public/modules/app-connection.js +169 -51
- package/lib/public/modules/app-messages.js +5 -3
- package/lib/public/modules/message-delivery-ui.js +63 -0
- package/lib/public/modules/message-delivery.js +163 -6
- package/lib/public/modules/websocket-lifecycle.js +178 -0
- package/lib/sessions.js +1 -1
- package/package.json +1 -1
|
@@ -273,7 +273,7 @@ function attachConnection(ctx) {
|
|
|
273
273
|
}
|
|
274
274
|
var _vendorCaps = (sm.capabilitiesByVendor && sm.capabilitiesByVendor[active.vendor || sm.defaultVendor || "claude"]) || {};
|
|
275
275
|
var activePermissionState = permissionModes.clientState(active, sm.currentPermissionMode, sm.defaultVendor);
|
|
276
|
-
sendTo(ws, { type: "session_switched", id: active.localId, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, model: active.model || null, effort: active.effort || initialEffort || null, hasHistory: (active.history && active.history.length > 0), capabilities: _vendorCaps, mode: active.mode || "gui", terminalId: typeof active.terminalId === "number" ? active.terminalId : null, runtimeMode: active.runtimeMode || null, runtimeTerminalId: typeof active.runtimeTerminalId === "number" ? active.runtimeTerminalId : null, tuiSuspended: !!active.tuiSuspended, permissionMode: activePermissionState.requestedPermissionMode, effectivePermissionMode: activePermissionState.effectivePermissionMode, permissionCapabilities: activePermissionState.permissionCapabilities, mcpPermissionModeOverrides: activePermissionState.mcpPermissionModeOverrides });
|
|
276
|
+
sendTo(ws, { type: "session_switched", id: active.localId, sessionOriginId: active.sessionOriginId || null, cliSessionId: active.cliSessionId || null, loop: active.loop || null, vendor: active.vendor || null, model: active.model || null, effort: active.effort || initialEffort || null, hasHistory: (active.history && active.history.length > 0), capabilities: _vendorCaps, mode: active.mode || "gui", terminalId: typeof active.terminalId === "number" ? active.terminalId : null, runtimeMode: active.runtimeMode || null, runtimeTerminalId: typeof active.runtimeTerminalId === "number" ? active.runtimeTerminalId : null, tuiSuspended: !!active.tuiSuspended, permissionMode: activePermissionState.requestedPermissionMode, effectivePermissionMode: activePermissionState.effectivePermissionMode, permissionCapabilities: activePermissionState.permissionCapabilities, mcpPermissionModeOverrides: activePermissionState.mcpPermissionModeOverrides });
|
|
277
277
|
if (ctx.autonomousRun) sendTo(ws, { type: "autonomous_run_state", sessionId: active.localId, run: ctx.autonomousRun.project(active.autonomousRun) });
|
|
278
278
|
// Send per-session context sources
|
|
279
279
|
var sessionSources = loadContextSources(slug, active.localId);
|
|
@@ -15,6 +15,25 @@ function deliveryKey(ownerId, clientMessageId) {
|
|
|
15
15
|
return ownerId + ":" + clientMessageId;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function validateContext(ws, session, msg, projectSlug) {
|
|
19
|
+
if (!msg || !msg.clientMessageId) return { ok: true };
|
|
20
|
+
if (msg.projectSlug == null && msg.sessionId == null && msg.accountId == null && msg.cliSessionId == null && msg.sessionOriginId == null) return { ok: true };
|
|
21
|
+
var expectedCliSessionId = session && session.cliSessionId ? String(session.cliSessionId) : null;
|
|
22
|
+
var actualCliSessionId = msg.cliSessionId ? String(msg.cliSessionId) : null;
|
|
23
|
+
var expectedSessionId = session && session.localId != null ? String(session.localId) : null;
|
|
24
|
+
var expectedOriginId = session && session.sessionOriginId ? String(session.sessionOriginId) : null;
|
|
25
|
+
var actualOriginId = msg.sessionOriginId ? String(msg.sessionOriginId) : null;
|
|
26
|
+
if (msg.projectSlug !== projectSlug) return { ok: false, reason: "Project changed" };
|
|
27
|
+
if (msg.accountId !== ownerKey(ws)) return { ok: false, reason: "Account changed" };
|
|
28
|
+
if (expectedOriginId || actualOriginId) {
|
|
29
|
+
if (!expectedOriginId || actualOriginId !== expectedOriginId) return { ok: false, reason: "Session identity changed" };
|
|
30
|
+
} else {
|
|
31
|
+
if (msg.sessionId == null || String(msg.sessionId) !== expectedSessionId) return { ok: false, reason: "Session changed" };
|
|
32
|
+
if (actualCliSessionId && expectedCliSessionId && actualCliSessionId !== expectedCliSessionId) return { ok: false, reason: "Session identity changed" };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
function ensureRecordedMessages(session) {
|
|
19
38
|
if (session._recordedClientMessages) return session._recordedClientMessages;
|
|
20
39
|
var recorded = new Map();
|
|
@@ -60,6 +79,7 @@ function createProjectMessageDelivery(sendTo, projectSlug) {
|
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
return {
|
|
82
|
+
validateContext: function (ws, session, msg) { return validateContext(ws, session, msg, projectSlug); },
|
|
63
83
|
inspect: inspect,
|
|
64
84
|
markRecorded: markRecorded,
|
|
65
85
|
acknowledge: acknowledge,
|
|
@@ -399,6 +399,13 @@ function attachUserMessage(ctx) {
|
|
|
399
399
|
if (pendingInternal && typeof pendingInternal.complete === "function") pendingInternal.complete(outcome, reason);
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
var deliveryContext = messageDelivery.validateContext(ws, session, msg);
|
|
403
|
+
if (!deliveryContext.ok) {
|
|
404
|
+
sendTo(ws, { type: "error", text: deliveryContext.reason || "Message context changed." });
|
|
405
|
+
finishPendingEarly(false, deliveryContext.reason || "Message context changed");
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
|
|
402
409
|
if (session.loopInterviewHandoff && session.loopInterviewHandoff.state === "starting") {
|
|
403
410
|
sendTo(ws, { type: "error", text: "Wait for the approved Loop handoff to finish." });
|
|
404
411
|
finishPendingEarly("release", "Loop handoff is still starting");
|
|
@@ -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 ---
|
|
@@ -98,6 +99,7 @@ var messagesEl = document.getElementById("messages");
|
|
|
98
99
|
var headerTitleEl = document.getElementById("header-title");
|
|
99
100
|
var inputEl = document.getElementById("input");
|
|
100
101
|
var connectOverlay = document.getElementById("connect-overlay");
|
|
102
|
+
initMessageDeliveryUi();
|
|
101
103
|
|
|
102
104
|
export function processMessage(msg) {
|
|
103
105
|
if (handleIssuesMessage(msg)) return;
|
|
@@ -839,10 +841,9 @@ export function processMessage(msg) {
|
|
|
839
841
|
var _effectiveTerminalId = (typeof msg.runtimeTerminalId === "number")
|
|
840
842
|
? msg.runtimeTerminalId
|
|
841
843
|
: (typeof msg.terminalId === "number" ? msg.terminalId : null);
|
|
842
|
-
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 || {} });
|
|
843
844
|
handleScheduledTaskSessionSwitched(msg);
|
|
844
845
|
requestLoopInterviewState();
|
|
845
|
-
|
|
846
|
+
activateDeliverySession(msg.id, msg.cliSessionId || null, msg.sessionOriginId || null);
|
|
846
847
|
// TUI sessions swap the chat UI for an embedded xterm running
|
|
847
848
|
// `claude` inside a real PTY. Mount or tear down before the rest of
|
|
848
849
|
// the chat-side bookkeeping runs so we don't waste work on hidden DOM.
|
|
@@ -936,6 +937,7 @@ export function processMessage(msg) {
|
|
|
936
937
|
|
|
937
938
|
case "session_id":
|
|
938
939
|
store.set({ cliSessionId: msg.cliSessionId });
|
|
940
|
+
refreshDeliverySessionIdentity(msg.cliSessionId, store.get('sessionOriginId') || null);
|
|
939
941
|
break;
|
|
940
942
|
|
|
941
943
|
case "message_uuid":
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// message-delivery-ui.js - receipt recovery notices
|
|
2
|
+
|
|
3
|
+
import { store } from './store.js';
|
|
4
|
+
import { retryPendingMessage } from './message-delivery.js';
|
|
5
|
+
|
|
6
|
+
var initialized = false;
|
|
7
|
+
var rendered = {};
|
|
8
|
+
var unsubscribe = null;
|
|
9
|
+
|
|
10
|
+
function currentContext(receipt) {
|
|
11
|
+
var currentOriginId = store.get('sessionOriginId') || null;
|
|
12
|
+
return receipt && receipt.projectSlug === store.get('currentSlug') && String(receipt.sessionId) === String(store.get('activeSessionId')) && receipt.accountId === (store.get('myUserId') || "_default") && (!receipt.sessionOriginId || !currentOriginId || receipt.sessionOriginId === currentOriginId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderReceipt(receipt) {
|
|
16
|
+
var messages = document.getElementById("messages");
|
|
17
|
+
if (!messages || !currentContext(receipt) || rendered[receipt.clientMessageId]) return;
|
|
18
|
+
var notice = document.createElement("div");
|
|
19
|
+
notice.className = "sys-msg error";
|
|
20
|
+
notice.dataset.deliveryReceipt = receipt.clientMessageId;
|
|
21
|
+
var text = document.createElement("span");
|
|
22
|
+
text.className = "sys-text";
|
|
23
|
+
text.textContent = "Receipt unconfirmed. Retry this message.";
|
|
24
|
+
notice.appendChild(text);
|
|
25
|
+
var retry = document.createElement("button");
|
|
26
|
+
retry.type = "button";
|
|
27
|
+
retry.className = "sys-retry";
|
|
28
|
+
retry.textContent = "Retry";
|
|
29
|
+
retry.addEventListener("click", function () { retryPendingMessage(receipt.clientMessageId); });
|
|
30
|
+
notice.appendChild(retry);
|
|
31
|
+
messages.appendChild(notice);
|
|
32
|
+
rendered[receipt.clientMessageId] = notice;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sync(state) {
|
|
36
|
+
var receipts = state.deliveryReceipts || {};
|
|
37
|
+
Object.keys(rendered).forEach(function (id) {
|
|
38
|
+
if (!rendered[id].parentNode || !receipts[id] || !currentContext(receipts[id])) {
|
|
39
|
+
if (rendered[id] && rendered[id].parentNode) rendered[id].parentNode.removeChild(rendered[id]);
|
|
40
|
+
delete rendered[id];
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
Object.keys(receipts).forEach(function (id) { renderReceipt(receipts[id]); });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function initMessageDeliveryUi() {
|
|
47
|
+
if (initialized) return;
|
|
48
|
+
initialized = true;
|
|
49
|
+
unsubscribe = store.subscribe(function (state, previous) {
|
|
50
|
+
if (state.deliveryReceipts !== previous.deliveryReceipts || state.currentSlug !== previous.currentSlug || state.activeSessionId !== previous.activeSessionId || state.sessionOriginId !== previous.sessionOriginId || state.myUserId !== previous.myUserId || state.replayingHistory !== previous.replayingHistory) sync(state);
|
|
51
|
+
});
|
|
52
|
+
sync(store.snap());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function disposeMessageDeliveryUi() {
|
|
56
|
+
Object.keys(rendered).forEach(function (id) {
|
|
57
|
+
if (rendered[id] && rendered[id].parentNode) rendered[id].parentNode.removeChild(rendered[id]);
|
|
58
|
+
});
|
|
59
|
+
rendered = {};
|
|
60
|
+
if (unsubscribe) unsubscribe();
|
|
61
|
+
unsubscribe = null;
|
|
62
|
+
initialized = false;
|
|
63
|
+
}
|
|
@@ -4,7 +4,10 @@ import { store } from './store.js';
|
|
|
4
4
|
import { getWs } from './ws-ref.js';
|
|
5
5
|
|
|
6
6
|
var ACK_TIMEOUT_MS = 5000;
|
|
7
|
+
var ACK_RETRY_LIMIT = 3;
|
|
7
8
|
var ackTimers = {};
|
|
9
|
+
var socketRefs = {};
|
|
10
|
+
var activatedContext = null;
|
|
8
11
|
|
|
9
12
|
function createClientMessageId() {
|
|
10
13
|
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
|
@@ -21,6 +24,39 @@ function replacePending(messages) {
|
|
|
21
24
|
store.set({ pendingOutboundMessages: messages });
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
function receipts() {
|
|
28
|
+
return store.get('deliveryReceipts') || {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setReceipt(entry) {
|
|
32
|
+
var next = Object.assign({}, receipts());
|
|
33
|
+
next[entry.payload.clientMessageId] = {
|
|
34
|
+
clientMessageId: entry.payload.clientMessageId,
|
|
35
|
+
projectSlug: entry.projectSlug,
|
|
36
|
+
sessionId: entry.sessionId,
|
|
37
|
+
accountId: entry.accountId,
|
|
38
|
+
sessionOriginId: entry.sessionOriginId || null,
|
|
39
|
+
attempts: entry.attempts
|
|
40
|
+
};
|
|
41
|
+
store.set({ deliveryReceipts: next });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function clearReceipt(clientMessageId) {
|
|
45
|
+
var current = receipts();
|
|
46
|
+
if (!current[clientMessageId]) return;
|
|
47
|
+
var next = Object.assign({}, current);
|
|
48
|
+
delete next[clientMessageId];
|
|
49
|
+
store.set({ deliveryReceipts: next });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function markUnconfirmed(entry) {
|
|
53
|
+
var unconfirmed = Object.assign({}, entry, { status: "unconfirmed" });
|
|
54
|
+
replacePending(pendingMessages().map(function (candidate) {
|
|
55
|
+
return candidate.payload.clientMessageId === entry.payload.clientMessageId ? unconfirmed : candidate;
|
|
56
|
+
}));
|
|
57
|
+
setReceipt(unconfirmed);
|
|
58
|
+
}
|
|
59
|
+
|
|
24
60
|
function clearAckTimer(clientMessageId) {
|
|
25
61
|
if (!ackTimers[clientMessageId]) return;
|
|
26
62
|
clearTimeout(ackTimers[clientMessageId]);
|
|
@@ -35,25 +71,92 @@ function isPending(clientMessageId) {
|
|
|
35
71
|
return false;
|
|
36
72
|
}
|
|
37
73
|
|
|
74
|
+
function pendingEntry(clientMessageId) {
|
|
75
|
+
var pending = pendingMessages();
|
|
76
|
+
for (var i = 0; i < pending.length; i++) {
|
|
77
|
+
if (pending[i].payload.clientMessageId === clientMessageId) return pending[i];
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matchesCurrentContext(entry) {
|
|
83
|
+
return entry && entry.projectSlug === store.get('currentSlug') && String(entry.sessionId) === String(store.get('activeSessionId')) && entry.accountId === (store.get('myUserId') || "_default");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasAutomaticReplayIdentity(entry, socket) {
|
|
87
|
+
if (!activatedContext || activatedContext.socket !== socket) return false;
|
|
88
|
+
if (entry.sessionOriginId && activatedContext.sessionOriginId && entry.sessionOriginId !== activatedContext.sessionOriginId) return false;
|
|
89
|
+
if (entry.sessionOriginId && activatedContext.sessionOriginId && entry.sessionOriginId === activatedContext.sessionOriginId) return true;
|
|
90
|
+
if (entry.cliSessionId && activatedContext.cliSessionId) return entry.cliSessionId === activatedContext.cliSessionId;
|
|
91
|
+
return socketRefs[entry.payload.clientMessageId] === socket;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasKnownCliMismatch(entry) {
|
|
95
|
+
if (entry.sessionOriginId && activatedContext && activatedContext.sessionOriginId && entry.sessionOriginId === activatedContext.sessionOriginId) return false;
|
|
96
|
+
return !!entry.cliSessionId && !!activatedContext && !!activatedContext.cliSessionId && entry.cliSessionId !== activatedContext.cliSessionId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function matchesActivatedContext(entry) {
|
|
100
|
+
var currentCliSessionId = store.get('cliSessionId') || null;
|
|
101
|
+
var sameOrigin = !!(entry.sessionOriginId && activatedContext && activatedContext.sessionOriginId && entry.sessionOriginId === activatedContext.sessionOriginId);
|
|
102
|
+
return !!activatedContext && activatedContext.socket === getWs() && matchesCurrentContext(entry) &&
|
|
103
|
+
activatedContext.projectSlug === store.get('currentSlug') && String(activatedContext.sessionId) === String(store.get('activeSessionId')) &&
|
|
104
|
+
activatedContext.accountId === (store.get('myUserId') || "_default") && (activatedContext.sessionOriginId || activatedContext.cliSessionId === currentCliSessionId) &&
|
|
105
|
+
(!entry.sessionOriginId || !activatedContext.sessionOriginId || entry.sessionOriginId === activatedContext.sessionOriginId) &&
|
|
106
|
+
(sameOrigin || !entry.cliSessionId || !activatedContext.cliSessionId || entry.cliSessionId === activatedContext.cliSessionId);
|
|
107
|
+
}
|
|
108
|
+
|
|
38
109
|
function armAckTimer(clientMessageId, socket) {
|
|
39
110
|
clearAckTimer(clientMessageId);
|
|
40
111
|
ackTimers[clientMessageId] = setTimeout(function () {
|
|
41
112
|
delete ackTimers[clientMessageId];
|
|
42
113
|
if (!isPending(clientMessageId) || getWs() !== socket) return;
|
|
114
|
+
var entry = pendingEntry(clientMessageId);
|
|
115
|
+
if (!matchesActivatedContext(entry)) return;
|
|
43
116
|
window.dispatchEvent(new CustomEvent("clay-message-delivery-timeout", {
|
|
44
117
|
detail: { socket: socket, clientMessageId: clientMessageId },
|
|
45
118
|
}));
|
|
119
|
+
var attempts = entry.attempts || 0;
|
|
120
|
+
if (attempts < ACK_RETRY_LIMIT && entry && socket.readyState === 1) {
|
|
121
|
+
var nextEntry = Object.assign({}, entry, { attempts: attempts + 1 });
|
|
122
|
+
replacePending(pendingMessages().map(function (candidate) {
|
|
123
|
+
return candidate.payload.clientMessageId === clientMessageId ? nextEntry : candidate;
|
|
124
|
+
}));
|
|
125
|
+
transmit(nextEntry, false);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (attempts >= ACK_RETRY_LIMIT) {
|
|
129
|
+
markUnconfirmed(entry);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
46
132
|
}, ACK_TIMEOUT_MS);
|
|
47
133
|
}
|
|
48
134
|
|
|
49
|
-
function transmit(entry) {
|
|
135
|
+
function transmit(entry, automatic) {
|
|
50
136
|
var socket = getWs();
|
|
51
|
-
if (!socket || socket.readyState !== 1) return false;
|
|
137
|
+
if (!matchesCurrentContext(entry) || !socket || socket.readyState !== 1) return false;
|
|
138
|
+
if (automatic && entry.status === "unconfirmed") return false;
|
|
139
|
+
if (automatic && !hasAutomaticReplayIdentity(entry, socket)) {
|
|
140
|
+
markUnconfirmed(entry);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (automatic) {
|
|
144
|
+
if ((entry.attempts || 0) >= ACK_RETRY_LIMIT) {
|
|
145
|
+
markUnconfirmed(entry);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
entry = Object.assign({}, entry, { attempts: (entry.attempts || 0) + 1 });
|
|
149
|
+
replacePending(pendingMessages().map(function (candidate) {
|
|
150
|
+
return candidate.payload.clientMessageId === entry.payload.clientMessageId ? entry : candidate;
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
socketRefs[entry.payload.clientMessageId] = socket;
|
|
52
154
|
try {
|
|
53
155
|
socket.send(JSON.stringify(entry.payload));
|
|
54
156
|
armAckTimer(entry.payload.clientMessageId, socket);
|
|
55
157
|
return true;
|
|
56
158
|
} catch (e) {
|
|
159
|
+
markUnconfirmed(entry);
|
|
57
160
|
return false;
|
|
58
161
|
}
|
|
59
162
|
}
|
|
@@ -63,10 +166,22 @@ export function sendAcknowledgedMessage(payload) {
|
|
|
63
166
|
var entry = {
|
|
64
167
|
projectSlug: store.get('currentSlug'),
|
|
65
168
|
sessionId: store.get('activeSessionId'),
|
|
169
|
+
accountId: store.get('myUserId') || "_default",
|
|
170
|
+
cliSessionId: store.get('cliSessionId') || null,
|
|
171
|
+
sessionOriginId: store.get('sessionOriginId') || null,
|
|
172
|
+
attempts: 0,
|
|
173
|
+
status: "pending",
|
|
66
174
|
payload: nextPayload,
|
|
67
175
|
};
|
|
176
|
+
nextPayload.projectSlug = entry.projectSlug;
|
|
177
|
+
nextPayload.sessionId = entry.sessionId;
|
|
178
|
+
nextPayload.accountId = entry.accountId;
|
|
179
|
+
nextPayload.cliSessionId = entry.cliSessionId;
|
|
180
|
+
nextPayload.sessionOriginId = entry.sessionOriginId;
|
|
181
|
+
entry.payload = nextPayload;
|
|
68
182
|
replacePending(pendingMessages().concat([entry]));
|
|
69
|
-
if (!transmit(entry)) {
|
|
183
|
+
if (!transmit(entry, false)) {
|
|
184
|
+
if (!getWs() || getWs().readyState !== 1) markUnconfirmed(entry);
|
|
70
185
|
window.dispatchEvent(new CustomEvent("clay-message-delivery-timeout", {
|
|
71
186
|
detail: { socket: getWs(), clientMessageId: nextPayload.clientMessageId },
|
|
72
187
|
}));
|
|
@@ -74,6 +189,21 @@ export function sendAcknowledgedMessage(payload) {
|
|
|
74
189
|
return nextPayload.clientMessageId;
|
|
75
190
|
}
|
|
76
191
|
|
|
192
|
+
export function retryPendingMessage(clientMessageId) {
|
|
193
|
+
var entry = pendingEntry(clientMessageId);
|
|
194
|
+
if (!matchesActivatedContext(entry) || hasKnownCliMismatch(entry)) return false;
|
|
195
|
+
var retryEntry = Object.assign({}, entry, { attempts: 0, status: "pending" });
|
|
196
|
+
replacePending(pendingMessages().map(function (candidate) {
|
|
197
|
+
return candidate.payload.clientMessageId === clientMessageId ? retryEntry : candidate;
|
|
198
|
+
}));
|
|
199
|
+
if (transmit(retryEntry, false)) {
|
|
200
|
+
clearReceipt(clientMessageId);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
markUnconfirmed(retryEntry);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
77
207
|
export function acknowledgeMessage(clientMessageId) {
|
|
78
208
|
if (typeof clientMessageId !== "string") return null;
|
|
79
209
|
var pending = pendingMessages();
|
|
@@ -82,9 +212,14 @@ export function acknowledgeMessage(clientMessageId) {
|
|
|
82
212
|
if (entry.payload.clientMessageId === clientMessageId) acknowledged = entry;
|
|
83
213
|
return entry.payload.clientMessageId !== clientMessageId;
|
|
84
214
|
});
|
|
85
|
-
if (next.length === pending.length)
|
|
215
|
+
if (next.length === pending.length) {
|
|
216
|
+
clearReceipt(clientMessageId);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
86
219
|
clearAckTimer(clientMessageId);
|
|
87
220
|
replacePending(next);
|
|
221
|
+
delete socketRefs[clientMessageId];
|
|
222
|
+
clearReceipt(clientMessageId);
|
|
88
223
|
return acknowledged;
|
|
89
224
|
}
|
|
90
225
|
|
|
@@ -92,8 +227,30 @@ export function replayPendingMessages(sessionId) {
|
|
|
92
227
|
var pending = pendingMessages();
|
|
93
228
|
var projectSlug = store.get('currentSlug');
|
|
94
229
|
for (var i = 0; i < pending.length; i++) {
|
|
95
|
-
if (pending[i].projectSlug === projectSlug && pending[i].sessionId === sessionId) {
|
|
96
|
-
transmit(pending[i]);
|
|
230
|
+
if (pending[i].projectSlug === projectSlug && String(pending[i].sessionId) === String(sessionId) && matchesCurrentContext(pending[i])) {
|
|
231
|
+
transmit(pending[i], true);
|
|
97
232
|
}
|
|
98
233
|
}
|
|
99
234
|
}
|
|
235
|
+
|
|
236
|
+
export function activateDeliverySession(sessionId, cliSessionId, sessionOriginId) {
|
|
237
|
+
var socket = getWs();
|
|
238
|
+
activatedContext = {
|
|
239
|
+
socket: socket,
|
|
240
|
+
projectSlug: store.get('currentSlug'),
|
|
241
|
+
sessionId: sessionId,
|
|
242
|
+
accountId: store.get('myUserId') || "_default",
|
|
243
|
+
cliSessionId: cliSessionId || null,
|
|
244
|
+
sessionOriginId: sessionOriginId || null
|
|
245
|
+
};
|
|
246
|
+
replayPendingMessages(sessionId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function refreshDeliverySessionIdentity(cliSessionId, sessionOriginId) {
|
|
250
|
+
if (!activatedContext || activatedContext.socket !== getWs()) return false;
|
|
251
|
+
if (activatedContext.projectSlug !== store.get('currentSlug') || String(activatedContext.sessionId) !== String(store.get('activeSessionId')) || activatedContext.accountId !== (store.get('myUserId') || "_default")) return false;
|
|
252
|
+
if (activatedContext.sessionOriginId && sessionOriginId && activatedContext.sessionOriginId !== sessionOriginId) return false;
|
|
253
|
+
activatedContext.cliSessionId = cliSessionId || null;
|
|
254
|
+
if (!activatedContext.sessionOriginId && sessionOriginId) activatedContext.sessionOriginId = sessionOriginId;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// websocket-lifecycle.js - bounded connection attempt and retry policy
|
|
2
|
+
|
|
3
|
+
var DEFAULTS = {
|
|
4
|
+
handshakeTimeoutMs: 12000,
|
|
5
|
+
stableConnectionMs: 15000,
|
|
6
|
+
retryMinMs: 1000,
|
|
7
|
+
retryMaxMs: 15000,
|
|
8
|
+
jitterRatio: 0.2,
|
|
9
|
+
authTimeoutMs: 4000
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function createWebSocketLifecycle(options) {
|
|
13
|
+
var config = Object.assign({}, DEFAULTS, options || {});
|
|
14
|
+
var epoch = 0;
|
|
15
|
+
var retryAttempt = 0;
|
|
16
|
+
var retryTimer = null;
|
|
17
|
+
var handshakeTimer = null;
|
|
18
|
+
var stableTimer = null;
|
|
19
|
+
var authTimer = null;
|
|
20
|
+
var authInFlight = false;
|
|
21
|
+
var authCancel = null;
|
|
22
|
+
var authGeneration = 0;
|
|
23
|
+
var stableEpoch = 0;
|
|
24
|
+
var cancelled = false;
|
|
25
|
+
var offline = false;
|
|
26
|
+
|
|
27
|
+
function clearTimer(timer) {
|
|
28
|
+
if (timer) clearTimeout(timer);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clearTimers() {
|
|
33
|
+
retryTimer = clearTimer(retryTimer);
|
|
34
|
+
handshakeTimer = clearTimer(handshakeTimer);
|
|
35
|
+
stableTimer = clearTimer(stableTimer);
|
|
36
|
+
authTimer = clearTimer(authTimer);
|
|
37
|
+
stableEpoch = 0;
|
|
38
|
+
if (authCancel) authCancel();
|
|
39
|
+
authCancel = null;
|
|
40
|
+
authInFlight = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function current(value) {
|
|
44
|
+
return value === epoch && !cancelled;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function canProceed(value) {
|
|
48
|
+
return current(value) && !offline;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function beginAttempt() {
|
|
52
|
+
clearTimers();
|
|
53
|
+
cancelled = false;
|
|
54
|
+
epoch += 1;
|
|
55
|
+
var attemptEpoch = epoch;
|
|
56
|
+
handshakeTimer = setTimeout(function () {
|
|
57
|
+
handshakeTimer = null;
|
|
58
|
+
if (current(attemptEpoch) && config.onHandshakeTimeout) config.onHandshakeTimeout(attemptEpoch);
|
|
59
|
+
}, config.handshakeTimeoutMs);
|
|
60
|
+
return attemptEpoch;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function delayForAttempt() {
|
|
64
|
+
var base = Math.min(config.retryMaxMs, config.retryMinMs * Math.pow(1.5, retryAttempt));
|
|
65
|
+
retryAttempt += 1;
|
|
66
|
+
var jitter = base * config.jitterRatio;
|
|
67
|
+
var random = config.random || Math.random;
|
|
68
|
+
return Math.min(config.retryMaxMs, Math.max(0, Math.round(base - jitter + random() * jitter * 2)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function schedule(reason, callback) {
|
|
72
|
+
if (cancelled || offline || retryTimer) return null;
|
|
73
|
+
var scheduledEpoch = epoch;
|
|
74
|
+
var delay = delayForAttempt();
|
|
75
|
+
retryTimer = setTimeout(function () {
|
|
76
|
+
retryTimer = null;
|
|
77
|
+
if (!canProceed(scheduledEpoch)) return;
|
|
78
|
+
callback(scheduledEpoch, reason, delay);
|
|
79
|
+
}, delay);
|
|
80
|
+
return { epoch: scheduledEpoch, delay: delay };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clearRetry() {
|
|
84
|
+
retryTimer = clearTimer(retryTimer);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function beginAuth(authEpoch, onTimeout, onCancel) {
|
|
88
|
+
if (!canProceed(authEpoch) || authInFlight) return null;
|
|
89
|
+
authInFlight = true;
|
|
90
|
+
authGeneration += 1;
|
|
91
|
+
var authToken = { epoch: authEpoch, generation: authGeneration };
|
|
92
|
+
authCancel = onCancel || null;
|
|
93
|
+
authTimer = setTimeout(function () {
|
|
94
|
+
authTimer = null;
|
|
95
|
+
authInFlight = false;
|
|
96
|
+
authCancel = null;
|
|
97
|
+
if (canProceed(authEpoch) && onTimeout) onTimeout(authEpoch);
|
|
98
|
+
}, config.authTimeoutMs);
|
|
99
|
+
return authToken;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function finishAuth(authEpoch, authToken) {
|
|
103
|
+
if (!authInFlight || !current(authEpoch) || !authToken || authToken.epoch !== authEpoch || authToken.generation !== authGeneration) return false;
|
|
104
|
+
authInFlight = false;
|
|
105
|
+
authCancel = null;
|
|
106
|
+
authTimer = clearTimer(authTimer);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function markOpen(attemptEpoch) {
|
|
111
|
+
if (!current(attemptEpoch)) return false;
|
|
112
|
+
handshakeTimer = clearTimer(handshakeTimer);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function markLive(attemptEpoch) {
|
|
117
|
+
if (!current(attemptEpoch)) return false;
|
|
118
|
+
handshakeTimer = clearTimer(handshakeTimer);
|
|
119
|
+
if (stableEpoch === attemptEpoch) return true;
|
|
120
|
+
stableEpoch = attemptEpoch;
|
|
121
|
+
stableTimer = clearTimer(stableTimer);
|
|
122
|
+
stableTimer = setTimeout(function () {
|
|
123
|
+
stableTimer = null;
|
|
124
|
+
if (current(attemptEpoch)) retryAttempt = 0;
|
|
125
|
+
}, config.stableConnectionMs);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function invalidate(attemptEpoch) {
|
|
130
|
+
if (attemptEpoch !== epoch) return false;
|
|
131
|
+
epoch += 1;
|
|
132
|
+
clearTimers();
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function cancel() {
|
|
137
|
+
cancelled = true;
|
|
138
|
+
epoch += 1;
|
|
139
|
+
clearTimers();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setOffline(value) {
|
|
143
|
+
var nextOffline = value === true;
|
|
144
|
+
if (nextOffline === offline) return offline;
|
|
145
|
+
offline = nextOffline;
|
|
146
|
+
if (offline) {
|
|
147
|
+
epoch += 1;
|
|
148
|
+
clearTimers();
|
|
149
|
+
}
|
|
150
|
+
return offline;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function wake(callback) {
|
|
154
|
+
offline = false;
|
|
155
|
+
if (!cancelled && callback) callback();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
beginAttempt: beginAttempt,
|
|
160
|
+
beginAuth: beginAuth,
|
|
161
|
+
cancel: cancel,
|
|
162
|
+
canProceed: canProceed,
|
|
163
|
+
clearRetry: clearRetry,
|
|
164
|
+
current: current,
|
|
165
|
+
finishAuth: finishAuth,
|
|
166
|
+
invalidate: invalidate,
|
|
167
|
+
markOpen: markOpen,
|
|
168
|
+
markLive: markLive,
|
|
169
|
+
schedule: schedule,
|
|
170
|
+
setOffline: setOffline,
|
|
171
|
+
wake: wake,
|
|
172
|
+
getRetryAttempt: function () { return retryAttempt; },
|
|
173
|
+
getEpoch: function () { return epoch; },
|
|
174
|
+
isOffline: function () { return offline; }
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export { createWebSocketLifecycle };
|
package/lib/sessions.js
CHANGED
|
@@ -846,7 +846,7 @@ function createSessionManager(opts) {
|
|
|
846
846
|
var _sessionVendor = session.vendor || defaultVendor || "claude";
|
|
847
847
|
var _vendorCaps = _capsByVendor[_sessionVendor] || {};
|
|
848
848
|
var _permissionState = require("./session-permission-mode").clientState(session, "default", defaultVendor);
|
|
849
|
-
_send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
|
|
849
|
+
_send({ type: "session_switched", id: localId, sessionOriginId: session.sessionOriginId || null, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
|
|
850
850
|
// Send vendor-specific slash commands
|
|
851
851
|
var _vendorCmds = (session.slashCommandsByVendor && session.slashCommandsByVendor[_sessionVendor])
|
|
852
852
|
|| session.slashCommands
|
package/package.json
CHANGED