@cjhyy/code-shell-web 0.9.39 → 0.9.41

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.
@@ -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
- wsRef.current?.close();
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 openWatchdog;
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
- // If we already have a live, open socket, leave it alone.
69
- if (ws && ws.readyState === WebSocket.OPEN)
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
- // Drop a half-open/connecting socket before redialing.
75
- if (ws) {
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
- // Watchdog: a socket stuck in CONNECTING (dead network, captive portal)
95
- // never fires onclose. Force-close after 10s so backoff kicks in.
96
- if (openWatchdog)
97
- clearTimeout(openWatchdog);
98
- openWatchdog = setTimeout(() => {
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 (openWatchdog)
110
- clearTimeout(openWatchdog);
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
- wsRef.current = null;
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
- return;
199
- statusRef.current = "offline";
200
- setStatus("offline");
201
- scheduleReconnect();
228
+ stopSocket.current?.();
202
229
  };
203
230
  ws.onerror = () => {
204
- // onclose handles retry; closing here makes the failure deterministic.
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
- return () => {
265
+ const stop = () => {
237
266
  closedByUs.current = true;
238
267
  if (retryTimer)
239
268
  clearTimeout(retryTimer);
240
- if (openWatchdog)
241
- clearTimeout(openWatchdog);
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.onmessage = null;
253
- ws.onopen = null;
254
- ws.onclose = null;
255
- ws.onerror = null;
256
- try {
257
- ws.close();
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",