@oar-sh/oar 0.9.11 → 0.9.13

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.
Files changed (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -0,0 +1,181 @@
1
+ // The spool a session worker writes a background task's output into when the
2
+ // provider keeps none in a file of its own (Copilot's attached shells, Grok's
3
+ // terminals): `<os.tmpdir()>/oar-task-output/<sessionId>/<taskId>.log`,
4
+ // append-only. The worker names the file as the task row's `outputPath`
5
+ // (`outputKind: 'spool'`) and the relay reads it like any other output file,
6
+ // so the console works the same for every provider and outlives a worker
7
+ // restart.
8
+ //
9
+ // Bounded: a file stops growing at 50 MB with one "output capped" line, and
10
+ // `sweep()` (at worker start and hourly) deletes files that ended more than an
11
+ // hour ago. Nothing is deleted at exit: the relay keeps showing an ended
12
+ // task's output for about an hour.
13
+
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ export const TASK_OUTPUT_SPOOL_DIR = 'oar-task-output';
19
+ export const TASK_OUTPUT_SPOOL_MAX_BYTES = 50 * 1024 * 1024;
20
+ export const TASK_OUTPUT_SPOOL_MAX_AGE_MS = 60 * 60 * 1000;
21
+ // A dead worker's session folder is left for its own next start to sweep;
22
+ // another session's worker removes it only once it is a day old.
23
+ export const TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
24
+
25
+ const CAPPED_LINE = '\n[output capped at 50 MB: later output is not kept]\n';
26
+
27
+ /** A file-name-safe form of an id: [A-Za-z0-9._-], at most 128 characters, never `.` or `..`. */
28
+ export function spoolSafeName(value) {
29
+ const text = String(value ?? '').trim().replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 128);
30
+ return /^\.+$/.test(text) ? text.replace(/\./g, '_') : text;
31
+ }
32
+
33
+ /**
34
+ * `createTaskOutputSpool({ sessionId })` →
35
+ * `{ root, pathFor(taskId), append(taskId, text), end(taskId), sweep(maxAgeMs) }`.
36
+ *
37
+ * - `pathFor(taskId)`: the absolute file path for the task's row.
38
+ * - `append(taskId, text)`: `{ ok, bytes, capped }`; never throws. The cap
39
+ * writes one marker line and drops what comes after it.
40
+ * - `end(taskId)`: the task finished; its file stays for the relay until a
41
+ * sweep finds it older than the age limit.
42
+ * - `sweep(maxAgeMs = 1 h)`: deletes this session's files not written to for
43
+ * that long (a task still appending is never swept), and other sessions'
44
+ * folders a day old. Returns the number of files removed.
45
+ *
46
+ * `baseDir` (default the OS temp dir) and `now` are for tests.
47
+ */
48
+ export function createTaskOutputSpool({
49
+ sessionId,
50
+ now = () => Date.now(),
51
+ baseDir = os.tmpdir(),
52
+ maxBytes = TASK_OUTPUT_SPOOL_MAX_BYTES,
53
+ fsImpl = fs,
54
+ } = {}) {
55
+ const sessionName = spoolSafeName(sessionId);
56
+ if (!sessionName) throw new Error('createTaskOutputSpool requires sessionId');
57
+ const top = path.join(path.resolve(baseDir), TASK_OUTPUT_SPOOL_DIR);
58
+ const root = path.join(top, sessionName);
59
+ // taskId → { bytes, capped }; a task listed here is live (still appending).
60
+ const live = new Map();
61
+ let rootReady = false;
62
+
63
+ // The shared folder is ours to write and sweep only when it is a real
64
+ // folder (not a symlink someone else put into a shared temp dir) owned by
65
+ // this user where the platform says who owns it.
66
+ function topIsOurs() {
67
+ let stats;
68
+ try { stats = fsImpl.lstatSync(top); } catch { return false; }
69
+ if (stats.isSymbolicLink() || !stats.isDirectory()) return false;
70
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
71
+ return uid === null || typeof stats.uid !== 'number' || stats.uid === uid;
72
+ }
73
+
74
+ function ensureRoot() {
75
+ if (rootReady) return;
76
+ // Owner-only: the temp dir is shared on a multi-user host.
77
+ try { fsImpl.mkdirSync(top, { mode: 0o700 }); } catch (error) { if (error?.code !== 'EEXIST') throw error; }
78
+ if (!topIsOurs()) throw new Error(`${top} is not a folder of this user`);
79
+ fsImpl.mkdirSync(root, { recursive: true, mode: 0o700 });
80
+ rootReady = true;
81
+ }
82
+
83
+ function pathFor(taskId) {
84
+ const name = spoolSafeName(taskId);
85
+ if (!name) throw new Error('task id is empty');
86
+ return path.join(root, `${name}.log`);
87
+ }
88
+
89
+ function stateOf(taskId, file) {
90
+ let state = live.get(taskId);
91
+ if (!state) {
92
+ let bytes = 0;
93
+ try { bytes = fsImpl.statSync(file).size; } catch {}
94
+ state = { bytes, capped: bytes >= maxBytes };
95
+ live.set(taskId, state);
96
+ }
97
+ return state;
98
+ }
99
+
100
+ function append(taskId, text) {
101
+ const id = String(taskId ?? '').trim();
102
+ const value = typeof text === 'string' ? text : (Buffer.isBuffer(text) ? text.toString('utf8') : String(text ?? ''));
103
+ if (!id) return { ok: false, bytes: 0, capped: false };
104
+ try {
105
+ ensureRoot();
106
+ const file = pathFor(id);
107
+ const state = stateOf(id, file);
108
+ if (state.capped || !value) return { ok: true, bytes: state.bytes, capped: state.capped };
109
+ let data = Buffer.from(value, 'utf8');
110
+ const room = maxBytes - state.bytes;
111
+ if (data.length > room) {
112
+ // Cut on a character boundary, then say so once.
113
+ let end = Math.max(0, room);
114
+ while (end > 0 && (data[end] & 0xc0) === 0x80) end -= 1;
115
+ data = Buffer.concat([data.subarray(0, end), Buffer.from(CAPPED_LINE, 'utf8')]);
116
+ state.capped = true;
117
+ }
118
+ fsImpl.appendFileSync(file, data, { mode: 0o600 });
119
+ state.bytes += data.length;
120
+ return { ok: true, bytes: state.bytes, capped: state.capped };
121
+ } catch {
122
+ return { ok: false, bytes: live.get(id)?.bytes || 0, capped: !!live.get(id)?.capped };
123
+ }
124
+ }
125
+
126
+ function end(taskId) {
127
+ live.delete(String(taskId ?? '').trim());
128
+ }
129
+
130
+ function liveNames() {
131
+ return new Set([...live.keys()].map((id) => `${spoolSafeName(id)}.log`));
132
+ }
133
+
134
+ function sweepDir(dir, maxAge, skip = new Set()) {
135
+ let removed = 0;
136
+ let left = 0;
137
+ let entries = [];
138
+ try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return { removed, left: 0, missing: true }; }
139
+ const cutoff = now() - maxAge;
140
+ for (const entry of entries) {
141
+ if (!entry.isFile() || skip.has(entry.name)) {
142
+ left += 1;
143
+ continue;
144
+ }
145
+ const file = path.join(dir, entry.name);
146
+ try {
147
+ if (fsImpl.statSync(file).mtimeMs < cutoff) {
148
+ fsImpl.unlinkSync(file);
149
+ removed += 1;
150
+ } else {
151
+ left += 1;
152
+ }
153
+ } catch {
154
+ left += 1;
155
+ }
156
+ }
157
+ return { removed, left, missing: false };
158
+ }
159
+
160
+ function sweep(maxAgeMs = TASK_OUTPUT_SPOOL_MAX_AGE_MS) {
161
+ const maxAge = Number.isFinite(Number(maxAgeMs)) && Number(maxAgeMs) >= 0 ? Number(maxAgeMs) : TASK_OUTPUT_SPOOL_MAX_AGE_MS;
162
+ // Never through a symlinked or foreign shared folder: its "session
163
+ // folders" could be anything.
164
+ if (!topIsOurs()) return 0;
165
+ let removed = sweepDir(root, maxAge, liveNames()).removed;
166
+ let folders = [];
167
+ try { folders = fsImpl.readdirSync(top, { withFileTypes: true }); } catch {}
168
+ for (const folder of folders) {
169
+ if (!folder.isDirectory() || folder.name === sessionName) continue;
170
+ const dir = path.join(top, folder.name);
171
+ const result = sweepDir(dir, Math.max(maxAge, TASK_OUTPUT_SPOOL_FOREIGN_MAX_AGE_MS));
172
+ removed += result.removed;
173
+ if (!result.missing && result.left === 0) {
174
+ try { fsImpl.rmdirSync(dir); } catch {}
175
+ }
176
+ }
177
+ return removed;
178
+ }
179
+
180
+ return { root, pathFor, append, end, sweep };
181
+ }
@@ -69,6 +69,15 @@ export function createWorkerWebSocketLink({
69
69
  minBackoffMs = 1000,
70
70
  maxBackoffMs = 8_000,
71
71
  jitterMs = 250,
72
+ // A connection counts as made only at the relay's first server.hello (the
73
+ // relay sends it as soon as it accepts the socket). One still connecting,
74
+ // or open but silent, when this runs out is given up and retried: a relay
75
+ // that hangs in its shutdown can hold a socket in either state without
76
+ // ever ending it.
77
+ connectTimeoutMs = 15_000,
78
+ // Failed attempts after the first few are logged at most once per this
79
+ // interval, with a count of the ones in between.
80
+ failureLogIntervalMs = 60_000,
72
81
  readyRefreshMs = 10_000,
73
82
  staleConnectionMs = 30_000,
74
83
  WebSocketImpl = globalThis.WebSocket,
@@ -84,6 +93,10 @@ export function createWorkerWebSocketLink({
84
93
  let readyRefreshTimer = null;
85
94
  let reconnectAttempt = 0;
86
95
  let reconnectDelayMs = 0;
96
+ // The current socket's connect deadline (see connectTimeoutMs).
97
+ let connectDeadlineTimer = null;
98
+ let lastFailureLoggedAt = null;
99
+ let failuresNotLogged = 0;
87
100
  // Ends the current socket's connection (see connect).
88
101
  let endCurrentSocket = null;
89
102
  // Insertion-ordered: the first entry is the oldest in-flight delivery, which
@@ -239,6 +252,12 @@ export function createWorkerWebSocketLink({
239
252
  reconnectTimer = null;
240
253
  }
241
254
 
255
+ function clearConnectDeadline() {
256
+ if (!connectDeadlineTimer) return;
257
+ clearTimeoutImpl(connectDeadlineTimer);
258
+ connectDeadlineTimer = null;
259
+ }
260
+
242
261
  function clearReadyRefreshTimer() {
243
262
  if (!readyRefreshTimer) return;
244
263
  clearIntervalImpl(readyRefreshTimer);
@@ -290,7 +309,23 @@ export function createWorkerWebSocketLink({
290
309
  if (typeof readyRefreshTimer?.unref === "function") readyRefreshTimer.unref();
291
310
  }
292
311
 
293
- function scheduleReconnect() {
312
+ // One line per failed attempt for the first few, then at most one per
313
+ // failureLogIntervalMs: a relay that stays away for an hour must not fill
314
+ // the worker log with a line every 8 seconds.
315
+ function logFailedAttempt(reason) {
316
+ const nowMs = getNowMs();
317
+ const intervalMs = Math.max(0, Number(failureLogIntervalMs) || 0);
318
+ if (reconnectAttempt > 3 && lastFailureLoggedAt !== null && (nowMs - lastFailureLoggedAt) < intervalMs) {
319
+ failuresNotLogged += 1;
320
+ return;
321
+ }
322
+ const notLogged = failuresNotLogged ? [`notLogged=${failuresNotLogged}`] : [];
323
+ dbg("worker ws disconnected", reason, `attempt=${reconnectAttempt}`, `retryInMs=${reconnectDelayMs}`, ...notLogged);
324
+ lastFailureLoggedAt = nowMs;
325
+ failuresNotLogged = 0;
326
+ }
327
+
328
+ function scheduleReconnect(reason) {
294
329
  if (stopped) return;
295
330
  // One timer at a time: a second end reported for the same outage must not
296
331
  // stack another attempt or push the backoff up.
@@ -299,7 +334,7 @@ export function createWorkerWebSocketLink({
299
334
  const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
300
335
  const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
301
336
  reconnectDelayMs = Math.min(maxBackoffMs, backoff + jitter);
302
- dbg("worker ws reconnect scheduled", `attempt=${reconnectAttempt}`, `delayMs=${reconnectDelayMs}`);
337
+ logFailedAttempt(reason);
303
338
  reconnectTimer = setTimeoutImpl(() => {
304
339
  reconnectTimer = null;
305
340
  connect();
@@ -312,42 +347,70 @@ export function createWorkerWebSocketLink({
312
347
  const openState = resolveSocketStateValue(ws, "OPEN", 1);
313
348
  const connectingState = resolveSocketStateValue(ws, "CONNECTING", 0);
314
349
  if (ws.readyState === openState || ws.readyState === connectingState) return;
350
+ // A socket left closing whose end was never reported: give it up
351
+ // quietly, this connect is its reconnect.
352
+ endCurrentSocket?.("replaced", { reconnect: false });
315
353
  }
316
354
  if (typeof WebSocketImpl !== "function") {
317
355
  dbg("worker ws unavailable: global WebSocket is missing");
318
356
  return;
319
357
  }
320
358
  clearReconnectTimer();
321
- const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
322
- const socket = new WebSocketImpl(url);
359
+ let socket = null;
360
+ try {
361
+ const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
362
+ socket = new WebSocketImpl(url);
363
+ } catch (error) {
364
+ // A socket that fails in its constructor raises no events: nothing but
365
+ // this would ever try again.
366
+ scheduleReconnect(`connect-failed: ${error?.message || String(error)}`);
367
+ return;
368
+ }
323
369
  ws = socket;
324
370
  let serverHelloSeen = false;
325
- // Set once this socket's end has been handled, by whichever of error and
326
- // close comes first; every later event from it is ignored. Node 22 fires
327
- // no close for a socket closed while still connecting, and closing it
328
- // from its own error handler raised the error again, so the reconnect
329
- // has to be scheduled from the error as well, and only once.
371
+ // Set once this socket's end has been handled, by whichever of error,
372
+ // close, the connect deadline or a stale/draining close comes first;
373
+ // every later event from it is ignored. Node 22 fires no close for a
374
+ // socket closed while still connecting, and closing it from its own
375
+ // error handler raised the error again, so the reconnect has to be
376
+ // scheduled from the error as well, and only once.
330
377
  let ended = false;
331
- function endSocket(reason) {
378
+ function endSocket(reason, { reconnect = true } = {}) {
332
379
  if (ended) return false;
333
380
  ended = true;
381
+ if (endCurrentSocket === endSocket) {
382
+ endCurrentSocket = null;
383
+ clearConnectDeadline();
384
+ }
334
385
  if (ws === socket) {
335
386
  ws = null;
336
387
  clearReadyRefreshTimer();
337
388
  // The next connection may reach a different (restarted) relay.
338
389
  serverCapabilities = new Set();
339
390
  }
340
- if (stopped) return true;
341
- dbg("worker ws disconnected", reason);
342
- scheduleReconnect();
391
+ if (stopped || !reconnect) return true;
392
+ // A newer socket is already under way and owns the reconnect.
393
+ if (ws) return true;
394
+ scheduleReconnect(reason);
343
395
  return true;
344
396
  }
345
397
  endCurrentSocket = endSocket;
398
+ clearConnectDeadline();
399
+ const deadlineMs = Math.max(0, Number(connectTimeoutMs) || 0);
400
+ if (deadlineMs > 0) {
401
+ connectDeadlineTimer = setTimeoutImpl(() => {
402
+ connectDeadlineTimer = null;
403
+ if (ended) return;
404
+ const opened = socket.readyState === resolveSocketStateValue(socket, "OPEN", 1);
405
+ // Ended before the close call, as in the error handler below.
406
+ endSocket(opened ? "no-server-hello" : "connect-timeout");
407
+ try { socket.close?.(); } catch {}
408
+ }, deadlineMs);
409
+ if (typeof connectDeadlineTimer?.unref === "function") connectDeadlineTimer.unref();
410
+ }
346
411
  socket.addEventListener("open", () => {
347
412
  if (ended) return;
348
413
  const openedAt = getNowMs();
349
- reconnectAttempt = 0;
350
- reconnectDelayMs = 0;
351
414
  lastOpenAt = openedAt;
352
415
  lastMessageAt = openedAt;
353
416
  dbg("worker ws connected");
@@ -373,6 +436,14 @@ export function createWorkerWebSocketLink({
373
436
  serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
374
437
  if (!serverHelloSeen) {
375
438
  serverHelloSeen = true;
439
+ // The handshake is complete: only now does the backoff start over.
440
+ // A relay that accepts the socket and drops it before its hello
441
+ // keeps backing off instead of being retried every second.
442
+ clearConnectDeadline();
443
+ reconnectAttempt = 0;
444
+ reconnectDelayMs = 0;
445
+ lastFailureLoggedAt = null;
446
+ failuresNotLogged = 0;
376
447
  try {
377
448
  onServerHello(String(payload.reason || "server-hello"));
378
449
  } catch (error) {
@@ -388,6 +459,9 @@ export function createWorkerWebSocketLink({
388
459
  }
389
460
  if (payload?.type === "server.draining") {
390
461
  dbg("worker ws draining", payload.reason || "relay-shutdown");
462
+ // A relay stuck in its shutdown may never complete the close
463
+ // handshake: the reconnect does not wait for the close event.
464
+ endSocket("draining");
391
465
  try { socket.close?.(); } catch {}
392
466
  return;
393
467
  }
@@ -408,15 +482,16 @@ export function createWorkerWebSocketLink({
408
482
  dbg("worker ws blocked", payload.reason || "blocked");
409
483
  }
410
484
  });
411
- socket.addEventListener("close", () => {
412
- endSocket("close");
485
+ socket.addEventListener("close", (event) => {
486
+ const code = Number(event?.code);
487
+ endSocket(Number.isFinite(code) && code > 0 ? `close code=${code}` : "close");
413
488
  });
414
- socket.addEventListener("error", (error) => {
489
+ socket.addEventListener("error", (event) => {
415
490
  if (ended) return;
416
- dbg("worker ws error", error?.message || String(error));
491
+ const detail = event?.message || event?.error?.message || "unknown";
417
492
  // Ended before the close call, so an error or close it raises
418
493
  // (synchronously on some runtimes) finds the socket already handled.
419
- endSocket("error");
494
+ endSocket(`error: ${detail}`);
420
495
  try { socket.close?.(); } catch {}
421
496
  });
422
497
  }
@@ -429,6 +504,7 @@ export function createWorkerWebSocketLink({
429
504
  function stop() {
430
505
  stopped = true;
431
506
  clearReconnectTimer();
507
+ clearConnectDeadline();
432
508
  clearReadyRefreshTimer();
433
509
  if (ws) {
434
510
  try { ws.close(); } catch {}