@cjhyy/code-shell-web 0.9.40 → 0.9.42
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/dist/hooks/useRemoteSocket.js +89 -69
- package/dist/lib/connectionLiveness.d.ts +28 -0
- package/dist/lib/connectionLiveness.js +105 -0
- package/dist/lib/streamReducer.js +1 -0
- package/dist-app/assets/index-DFx6OGDf.js +57 -0
- package/dist-app/index.html +1 -1
- package/package.json +3 -3
- package/dist-app/assets/index-CxKasYu2.js +0 -57
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { deviceStore } from "../lib/storage.js";
|
|
3
3
|
import { generateSecret } from "../lib/deviceCredential.js";
|
|
4
4
|
import { parsePairingToken } from "../lib/pairing.js";
|
|
5
|
+
import { ConnectionLiveness, connectionPageHidden } from "../lib/connectionLiveness.js";
|
|
5
6
|
const RECONNECT_BASE_MS = 800;
|
|
6
7
|
const RECONNECT_MAX_MS = 8000;
|
|
7
8
|
export function useRemoteSocket(handlers) {
|
|
@@ -12,6 +13,7 @@ export function useRemoteSocket(handlers) {
|
|
|
12
13
|
const connectionGenerationRef = useRef(0);
|
|
13
14
|
const retryRef = useRef(0);
|
|
14
15
|
const closedByUs = useRef(false);
|
|
16
|
+
const stopSocket = useRef(undefined);
|
|
15
17
|
const statusRef = useRef(status);
|
|
16
18
|
statusRef.current = status;
|
|
17
19
|
// Keep latest handlers without retriggering the connect effect.
|
|
@@ -36,7 +38,7 @@ export function useRemoteSocket(handlers) {
|
|
|
36
38
|
const logout = useCallback(() => {
|
|
37
39
|
deviceStore.clearId();
|
|
38
40
|
closedByUs.current = true;
|
|
39
|
-
|
|
41
|
+
stopSocket.current?.();
|
|
40
42
|
// Drop the pairing token from the URL and reload into the unpaired state.
|
|
41
43
|
window.history.replaceState(null, "", window.location.pathname);
|
|
42
44
|
statusRef.current = "unpaired";
|
|
@@ -45,11 +47,11 @@ export function useRemoteSocket(handlers) {
|
|
|
45
47
|
useEffect(() => {
|
|
46
48
|
closedByUs.current = false;
|
|
47
49
|
let retryTimer;
|
|
48
|
-
let
|
|
50
|
+
let health;
|
|
49
51
|
/** Schedule a reconnect with jittered exponential backoff. Cancels any
|
|
50
52
|
* pending timer so a manual/event-driven reconnect can pre-empt it. */
|
|
51
53
|
const scheduleReconnect = () => {
|
|
52
|
-
if (closedByUs.current)
|
|
54
|
+
if (closedByUs.current || connectionPageHidden())
|
|
53
55
|
return;
|
|
54
56
|
if (retryTimer)
|
|
55
57
|
clearTimeout(retryTimer);
|
|
@@ -60,54 +62,60 @@ export function useRemoteSocket(handlers) {
|
|
|
60
62
|
retryRef.current += 1;
|
|
61
63
|
retryTimer = setTimeout(connect, delay);
|
|
62
64
|
};
|
|
65
|
+
const retire = (ws, retry = true) => {
|
|
66
|
+
if (wsRef.current !== ws)
|
|
67
|
+
return;
|
|
68
|
+
wsRef.current = null;
|
|
69
|
+
health?.stop();
|
|
70
|
+
health = undefined;
|
|
71
|
+
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
|
|
72
|
+
try {
|
|
73
|
+
ws.close();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* already unusable */
|
|
77
|
+
}
|
|
78
|
+
if (!closedByUs.current) {
|
|
79
|
+
statusRef.current = "offline";
|
|
80
|
+
setStatus("offline");
|
|
81
|
+
if (retry)
|
|
82
|
+
scheduleReconnect();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
63
85
|
/** Force an immediate reconnect (network back / tab visible again). */
|
|
64
86
|
const reconnectNow = () => {
|
|
65
87
|
if (closedByUs.current)
|
|
66
88
|
return;
|
|
67
89
|
const ws = wsRef.current;
|
|
68
|
-
|
|
69
|
-
|
|
90
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
91
|
+
health?.resume();
|
|
70
92
|
return;
|
|
93
|
+
}
|
|
71
94
|
if (retryTimer)
|
|
72
95
|
clearTimeout(retryTimer);
|
|
73
96
|
retryRef.current = 0;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
ws.onclose = null;
|
|
77
|
-
ws.onerror = null;
|
|
78
|
-
try {
|
|
79
|
-
ws.close();
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
/* ignore */
|
|
83
|
-
}
|
|
84
|
-
wsRef.current = null;
|
|
85
|
-
}
|
|
97
|
+
if (ws)
|
|
98
|
+
retire(ws, false);
|
|
86
99
|
connect();
|
|
87
100
|
};
|
|
88
101
|
const connect = () => {
|
|
102
|
+
if (closedByUs.current)
|
|
103
|
+
return;
|
|
104
|
+
retryTimer = undefined;
|
|
89
105
|
const wsUrl = window.location.origin.replace(/^http/, "ws") + "/ws";
|
|
90
106
|
const ws = new WebSocket(wsUrl);
|
|
91
107
|
wsRef.current = ws;
|
|
92
108
|
statusRef.current = "connecting";
|
|
93
109
|
setStatus("connecting");
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (ws.readyState === WebSocket.CONNECTING) {
|
|
100
|
-
try {
|
|
101
|
-
ws.close();
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
/* ignore */
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}, 10_000);
|
|
110
|
+
const socketHealth = new ConnectionLiveness(
|
|
111
|
+
// Existing read-only protocol, answered by the Host's disk index. Never
|
|
112
|
+
// manufacture a ping, repeat a chat send or retry an approval decision.
|
|
113
|
+
() => ws.send(JSON.stringify({ type: "session.list" })), () => retire(ws), connectionPageHidden());
|
|
114
|
+
health = socketHealth;
|
|
108
115
|
ws.onopen = () => {
|
|
109
|
-
if (
|
|
110
|
-
|
|
116
|
+
if (wsRef.current !== ws || closedByUs.current)
|
|
117
|
+
return;
|
|
118
|
+
socketHealth.authenticating();
|
|
111
119
|
retryRef.current = 0;
|
|
112
120
|
const pairingToken = parsePairingToken(window.location.search);
|
|
113
121
|
const secret = deviceStore.getOrCreateSecret(() => generateSecret());
|
|
@@ -133,12 +141,13 @@ export function useRemoteSocket(handlers) {
|
|
|
133
141
|
}));
|
|
134
142
|
}
|
|
135
143
|
else {
|
|
144
|
+
socketHealth.stop();
|
|
136
145
|
statusRef.current = "unpaired";
|
|
137
146
|
setStatus("unpaired");
|
|
138
147
|
}
|
|
139
148
|
};
|
|
140
149
|
ws.onmessage = (e) => {
|
|
141
|
-
if (wsRef.current !== ws)
|
|
150
|
+
if (wsRef.current !== ws || closedByUs.current)
|
|
142
151
|
return;
|
|
143
152
|
let msg;
|
|
144
153
|
try {
|
|
@@ -147,7 +156,20 @@ export function useRemoteSocket(handlers) {
|
|
|
147
156
|
catch {
|
|
148
157
|
return;
|
|
149
158
|
}
|
|
159
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg))
|
|
160
|
+
return;
|
|
150
161
|
const obj = msg;
|
|
162
|
+
// This legacy protocol has no request id on session.list. Only its
|
|
163
|
+
// concrete response (or an uncorrelated control rejection) can answer
|
|
164
|
+
// a health check; stream/approval notifications cannot clear a deadline.
|
|
165
|
+
if (obj.type === "session.list.ok" ||
|
|
166
|
+
(obj.type === "error" &&
|
|
167
|
+
!obj.approvalId &&
|
|
168
|
+
!obj.clientMessageId &&
|
|
169
|
+
!obj.clientRequestId &&
|
|
170
|
+
!obj.sessionId &&
|
|
171
|
+
!obj.roomId))
|
|
172
|
+
socketHealth.received();
|
|
151
173
|
// Auth/pair lifecycle is handled here so the hook owns the state machine.
|
|
152
174
|
if (obj.type === "pair.ok") {
|
|
153
175
|
const device = obj.device;
|
|
@@ -160,6 +182,7 @@ export function useRemoteSocket(handlers) {
|
|
|
160
182
|
return;
|
|
161
183
|
}
|
|
162
184
|
if (obj.type === "auth.ok") {
|
|
185
|
+
socketHealth.connected();
|
|
163
186
|
const device = obj.device;
|
|
164
187
|
if (device?.name)
|
|
165
188
|
setDeviceName(device.name);
|
|
@@ -172,11 +195,14 @@ export function useRemoteSocket(handlers) {
|
|
|
172
195
|
return;
|
|
173
196
|
}
|
|
174
197
|
if (obj.type === "auth.failed" || obj.type === "pair.failed") {
|
|
198
|
+
closedByUs.current = true;
|
|
175
199
|
if (obj.type === "auth.failed")
|
|
176
200
|
deviceStore.clearId();
|
|
177
201
|
statusRef.current = "unpaired";
|
|
178
202
|
setStatus("unpaired");
|
|
179
203
|
handlersRef.current.onServerEvent?.(msg);
|
|
204
|
+
retire(ws, false);
|
|
205
|
+
stopSocket.current?.();
|
|
180
206
|
return;
|
|
181
207
|
}
|
|
182
208
|
// Typed server events carry a string `type`; raw worker lines carry a
|
|
@@ -188,34 +214,32 @@ export function useRemoteSocket(handlers) {
|
|
|
188
214
|
handlersRef.current.onRawLine?.(msg);
|
|
189
215
|
}
|
|
190
216
|
};
|
|
191
|
-
ws.onclose = () => {
|
|
192
|
-
if (openWatchdog)
|
|
193
|
-
clearTimeout(openWatchdog);
|
|
217
|
+
ws.onclose = (event) => {
|
|
194
218
|
if (wsRef.current !== ws)
|
|
195
219
|
return;
|
|
196
|
-
|
|
220
|
+
if (event?.code === 4401 || event?.code === 4403) {
|
|
221
|
+
closedByUs.current = true;
|
|
222
|
+
deviceStore.clearId();
|
|
223
|
+
statusRef.current = "unpaired";
|
|
224
|
+
setStatus("unpaired");
|
|
225
|
+
}
|
|
226
|
+
retire(ws);
|
|
197
227
|
if (closedByUs.current)
|
|
198
|
-
|
|
199
|
-
statusRef.current = "offline";
|
|
200
|
-
setStatus("offline");
|
|
201
|
-
scheduleReconnect();
|
|
228
|
+
stopSocket.current?.();
|
|
202
229
|
};
|
|
203
230
|
ws.onerror = () => {
|
|
204
|
-
|
|
205
|
-
try {
|
|
206
|
-
ws.close();
|
|
207
|
-
}
|
|
208
|
-
catch {
|
|
209
|
-
/* ignore */
|
|
210
|
-
}
|
|
231
|
+
retire(ws);
|
|
211
232
|
};
|
|
212
233
|
};
|
|
213
234
|
// Reconnect proactively when the OS reports the network is back or the tab
|
|
214
235
|
// becomes visible again (phones suspend the socket in the background — the
|
|
215
236
|
// close event often doesn't fire until the user returns).
|
|
216
237
|
const resyncOrReconnect = (reason) => {
|
|
238
|
+
if (closedByUs.current || connectionPageHidden())
|
|
239
|
+
return;
|
|
217
240
|
const ws = wsRef.current;
|
|
218
241
|
if (ws && ws.readyState === WebSocket.OPEN && statusRef.current === "online") {
|
|
242
|
+
health?.resume();
|
|
219
243
|
handlersRef.current.onResyncNeeded?.(reason);
|
|
220
244
|
return;
|
|
221
245
|
}
|
|
@@ -225,6 +249,11 @@ export function useRemoteSocket(handlers) {
|
|
|
225
249
|
const onVisible = () => {
|
|
226
250
|
if (document.visibilityState === "visible")
|
|
227
251
|
resyncOrReconnect("visible");
|
|
252
|
+
else {
|
|
253
|
+
health?.pause();
|
|
254
|
+
if (retryTimer)
|
|
255
|
+
clearTimeout(retryTimer);
|
|
256
|
+
}
|
|
228
257
|
};
|
|
229
258
|
const onFocus = () => resyncOrReconnect("focus");
|
|
230
259
|
const onPageShow = () => resyncOrReconnect("pageshow");
|
|
@@ -233,34 +262,25 @@ export function useRemoteSocket(handlers) {
|
|
|
233
262
|
window.addEventListener("pageshow", onPageShow);
|
|
234
263
|
document.addEventListener("visibilitychange", onVisible);
|
|
235
264
|
connect();
|
|
236
|
-
|
|
265
|
+
const stop = () => {
|
|
237
266
|
closedByUs.current = true;
|
|
238
267
|
if (retryTimer)
|
|
239
268
|
clearTimeout(retryTimer);
|
|
240
|
-
|
|
241
|
-
|
|
269
|
+
health?.stop();
|
|
270
|
+
health = undefined;
|
|
242
271
|
window.removeEventListener("online", onOnline);
|
|
243
272
|
window.removeEventListener("focus", onFocus);
|
|
244
273
|
window.removeEventListener("pageshow", onPageShow);
|
|
245
274
|
document.removeEventListener("visibilitychange", onVisible);
|
|
246
|
-
// Null ALL handlers before close: onmessage/onopen call setState without the
|
|
247
|
-
// closedByUs guard the close/error paths have, so an event landing in the
|
|
248
|
-
// close window would setState on the unmounted component. (Mirrors the
|
|
249
|
-
// reconnectNow teardown above, which already nulls handlers.)
|
|
250
275
|
const ws = wsRef.current;
|
|
251
|
-
if (ws)
|
|
252
|
-
ws
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
catch {
|
|
260
|
-
/* ignore */
|
|
261
|
-
}
|
|
262
|
-
wsRef.current = null;
|
|
263
|
-
}
|
|
276
|
+
if (ws)
|
|
277
|
+
retire(ws, false);
|
|
278
|
+
};
|
|
279
|
+
stopSocket.current = stop;
|
|
280
|
+
return () => {
|
|
281
|
+
stop();
|
|
282
|
+
if (stopSocket.current === stop)
|
|
283
|
+
stopSocket.current = undefined;
|
|
264
284
|
};
|
|
265
285
|
}, []);
|
|
266
286
|
return { status, deviceName, connectionGeneration, send, logout };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** One instance per socket. Retired sockets and queued background timers cannot
|
|
2
|
+
* change the next connection's state. Any explicit response, including a denied
|
|
3
|
+
* read-only probe, proves transport liveness; it does not grant permission. */
|
|
4
|
+
export declare class ConnectionLiveness {
|
|
5
|
+
private readonly sendProbe;
|
|
6
|
+
private readonly expire;
|
|
7
|
+
private timer?;
|
|
8
|
+
private revision;
|
|
9
|
+
private stopped;
|
|
10
|
+
private paused;
|
|
11
|
+
private ready;
|
|
12
|
+
private opened;
|
|
13
|
+
private probing;
|
|
14
|
+
constructor(sendProbe: () => void, expire: () => void, paused?: boolean);
|
|
15
|
+
connected(): void;
|
|
16
|
+
/** Native pairing/authentication gets its own bounded phase after open. */
|
|
17
|
+
authenticating(): void;
|
|
18
|
+
/** Return whether this frame answered the current single-flight health check. */
|
|
19
|
+
received(): boolean;
|
|
20
|
+
check(): void;
|
|
21
|
+
pause(): void;
|
|
22
|
+
resume(): void;
|
|
23
|
+
stop(): void;
|
|
24
|
+
private clear;
|
|
25
|
+
private arm;
|
|
26
|
+
private fail;
|
|
27
|
+
}
|
|
28
|
+
export declare function connectionPageHidden(): boolean;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Connection health only: a long-running task is never given a deadline here. */
|
|
2
|
+
const CONNECT_MS = 10_000;
|
|
3
|
+
const CHECK_MS = 15_000;
|
|
4
|
+
const PROBE_MS = 5_000;
|
|
5
|
+
/** One instance per socket. Retired sockets and queued background timers cannot
|
|
6
|
+
* change the next connection's state. Any explicit response, including a denied
|
|
7
|
+
* read-only probe, proves transport liveness; it does not grant permission. */
|
|
8
|
+
export class ConnectionLiveness {
|
|
9
|
+
sendProbe;
|
|
10
|
+
expire;
|
|
11
|
+
timer;
|
|
12
|
+
revision = 0;
|
|
13
|
+
stopped = false;
|
|
14
|
+
paused;
|
|
15
|
+
ready = false;
|
|
16
|
+
opened = false;
|
|
17
|
+
probing = false;
|
|
18
|
+
constructor(sendProbe, expire, paused = false) {
|
|
19
|
+
this.sendProbe = sendProbe;
|
|
20
|
+
this.expire = expire;
|
|
21
|
+
this.paused = paused;
|
|
22
|
+
this.arm(CONNECT_MS, () => this.fail());
|
|
23
|
+
}
|
|
24
|
+
connected() {
|
|
25
|
+
if (this.stopped)
|
|
26
|
+
return;
|
|
27
|
+
this.ready = true;
|
|
28
|
+
this.arm(CHECK_MS, () => this.check());
|
|
29
|
+
}
|
|
30
|
+
/** Native pairing/authentication gets its own bounded phase after open. */
|
|
31
|
+
authenticating() {
|
|
32
|
+
if (this.stopped || this.ready || this.opened)
|
|
33
|
+
return;
|
|
34
|
+
this.opened = true;
|
|
35
|
+
this.arm(CONNECT_MS, () => this.fail());
|
|
36
|
+
}
|
|
37
|
+
/** Return whether this frame answered the current single-flight health check. */
|
|
38
|
+
received() {
|
|
39
|
+
if (this.stopped || this.paused || !this.probing)
|
|
40
|
+
return false;
|
|
41
|
+
this.probing = false;
|
|
42
|
+
this.arm(CHECK_MS, () => this.check());
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
check() {
|
|
46
|
+
if (this.stopped || this.paused || this.probing || !this.ready)
|
|
47
|
+
return;
|
|
48
|
+
this.probing = true;
|
|
49
|
+
// Arm before send: a synchronous response must be able to clear this timer.
|
|
50
|
+
this.arm(PROBE_MS, () => this.fail());
|
|
51
|
+
try {
|
|
52
|
+
this.sendProbe();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
this.fail();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
pause() {
|
|
59
|
+
this.paused = true;
|
|
60
|
+
this.probing = false;
|
|
61
|
+
this.clear();
|
|
62
|
+
}
|
|
63
|
+
resume() {
|
|
64
|
+
if (this.stopped)
|
|
65
|
+
return;
|
|
66
|
+
const wasPaused = this.paused;
|
|
67
|
+
this.paused = false;
|
|
68
|
+
if (this.ready)
|
|
69
|
+
this.check();
|
|
70
|
+
else if (wasPaused)
|
|
71
|
+
this.arm(CONNECT_MS, () => this.fail());
|
|
72
|
+
}
|
|
73
|
+
stop() {
|
|
74
|
+
this.stopped = true;
|
|
75
|
+
this.probing = false;
|
|
76
|
+
this.clear();
|
|
77
|
+
}
|
|
78
|
+
clear() {
|
|
79
|
+
this.revision++;
|
|
80
|
+
if (this.timer !== undefined)
|
|
81
|
+
clearTimeout(this.timer);
|
|
82
|
+
this.timer = undefined;
|
|
83
|
+
}
|
|
84
|
+
arm(ms, run) {
|
|
85
|
+
this.clear();
|
|
86
|
+
if (this.stopped || this.paused)
|
|
87
|
+
return;
|
|
88
|
+
const revision = this.revision;
|
|
89
|
+
this.timer = setTimeout(() => {
|
|
90
|
+
if (this.stopped || this.paused || revision !== this.revision)
|
|
91
|
+
return;
|
|
92
|
+
this.timer = undefined;
|
|
93
|
+
run();
|
|
94
|
+
}, ms);
|
|
95
|
+
}
|
|
96
|
+
fail() {
|
|
97
|
+
if (this.stopped)
|
|
98
|
+
return;
|
|
99
|
+
this.stop();
|
|
100
|
+
this.expire();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export function connectionPageHidden() {
|
|
104
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
105
|
+
}
|
|
@@ -55,6 +55,7 @@ function runStateForReason(reason) {
|
|
|
55
55
|
case "model_error":
|
|
56
56
|
case "image_error":
|
|
57
57
|
case "replay_incomplete":
|
|
58
|
+
case "batch_incomplete":
|
|
58
59
|
case "unverified_write":
|
|
59
60
|
return "error";
|
|
60
61
|
// "completed", "max_turns", "goal_budget_exhausted", "stop_hook_prevented",
|