@oar-sh/oar 0.9.12 → 0.9.14

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 (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -2,6 +2,9 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import { fileURLToPath } from "url";
4
4
 
5
+ import { RESCUE_DIR_NAME } from "../../server/services/earlier-database-service.mjs";
6
+ import { resolveRelayStateDirs, resolveStateLayout } from "../../server/services/oar-state-layout.mjs";
7
+
5
8
  function toAbsolutePath(value) {
6
9
  if (!value) return "";
7
10
  return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
@@ -12,15 +15,21 @@ function looksLikeServerDir(serverDir) {
12
15
  return fs.existsSync(path.join(serverDir, "server.js")) && fs.existsSync(path.join(serverDir, "config.json"));
13
16
  }
14
17
 
15
- export function resolveRelayPaths(importMetaUrl) {
18
+ /**
19
+ * Where the extension finds the relay, its config and logs. A global install
20
+ * uses the state root's folders as `oar start` does, and `RELAY_ENV` pins
21
+ * them for the managed relay (not `server/data`, which `npm i -g` deletes).
22
+ * The environment wins; a checkout keeps its `server/` folders.
23
+ */
24
+ export function resolveRelayPaths(importMetaUrl, { env = process.env, platform = process.platform } = {}) {
16
25
  const __dirname = path.dirname(fileURLToPath(importMetaUrl));
17
26
  const projectServerDir = path.resolve(__dirname, "../../../server");
18
27
  const cwdServerDir = path.resolve(process.cwd(), "server");
19
- const envRoot = toAbsolutePath(process.env.COPILOT_WEB_RELAY_ROOT || "");
20
- const envServerDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_SERVER_DIR || "");
21
- const envConfigPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_CONFIG || "");
22
- const envToolsPath = toAbsolutePath(process.env.COPILOT_WEB_RELAY_TOOLS || "");
23
- const envLogDir = toAbsolutePath(process.env.COPILOT_WEB_RELAY_LOG_DIR || "");
28
+ const envRoot = toAbsolutePath(env.COPILOT_WEB_RELAY_ROOT || "");
29
+ const envServerDir = toAbsolutePath(env.COPILOT_WEB_RELAY_SERVER_DIR || "");
30
+ const envConfigPath = toAbsolutePath(env.COPILOT_WEB_RELAY_CONFIG || "");
31
+ const envToolsPath = toAbsolutePath(env.COPILOT_WEB_RELAY_TOOLS || "");
32
+ const envLogDir = toAbsolutePath(env.COPILOT_WEB_RELAY_LOG_DIR || "");
24
33
 
25
34
  const resolvedServerDir =
26
35
  (looksLikeServerDir(envServerDir) && envServerDir) ||
@@ -30,11 +39,23 @@ export function resolveRelayPaths(importMetaUrl) {
30
39
  projectServerDir;
31
40
 
32
41
  const SERVER_DIR = resolvedServerDir;
33
- const CONFIG_PATH = envConfigPath || path.resolve(SERVER_DIR, "config.json");
42
+ const packageRoot = path.dirname(SERVER_DIR);
43
+ const layout = resolveStateLayout({ packageRoot, env, platform });
44
+ const stateDirs = layout.checkout ? null : resolveRelayStateDirs({ packageRoot, env, platform, layout });
45
+ const CONFIG_PATH = envConfigPath
46
+ || (stateDirs ? path.join(layout.configDir, "config.json") : path.resolve(SERVER_DIR, "config.json"));
34
47
  const RELAY_TOOLS_PATH = envToolsPath || path.resolve(SERVER_DIR, "relay-tools.md");
35
- const LOG_DIR = envLogDir || path.resolve(SERVER_DIR, "logs");
48
+ const LOG_DIR = envLogDir || stateDirs?.logDir || path.resolve(SERVER_DIR, "logs");
36
49
  const SERVER_LOG_PATH = path.resolve(LOG_DIR, "server.log");
37
50
  const SERVER_ERR_PATH = path.resolve(LOG_DIR, "server-err.log");
51
+ const RELAY_ENV = stateDirs
52
+ ? {
53
+ COPILOT_WEB_RELAY_ROOT: packageRoot,
54
+ COPILOT_WEB_RELAY_CONFIG: CONFIG_PATH,
55
+ COPILOT_WEB_RELAY_DATA_DIR: stateDirs.dataDir,
56
+ COPILOT_WEB_RELAY_LOG_DIR: LOG_DIR,
57
+ }
58
+ : {};
38
59
 
39
60
  return {
40
61
  __dirname,
@@ -44,9 +65,34 @@ export function resolveRelayPaths(importMetaUrl) {
44
65
  LOG_DIR,
45
66
  SERVER_LOG_PATH,
46
67
  SERVER_ERR_PATH,
68
+ RELAY_ENV,
47
69
  };
48
70
  }
49
71
 
72
+ /**
73
+ * The package's `server/config.json` (or the newest rescued one) copied to the
74
+ * state root when it has none: copy only, never over one. Returns the source.
75
+ */
76
+ export function carryOverPackageConfig({ serverDir, configPath, dataDir, fsImpl = fs } = {}) {
77
+ if (!configPath || !dataDir || fsImpl.existsSync(configPath)) return null;
78
+ const sources = [path.join(serverDir, "config.json")];
79
+ try {
80
+ const rescueRoot = path.join(dataDir, RESCUE_DIR_NAME);
81
+ for (const name of fsImpl.readdirSync(rescueRoot).filter((entry) => !entry.endsWith(".partial")).sort().reverse()) {
82
+ sources.push(path.join(rescueRoot, name, "config.json"));
83
+ }
84
+ } catch {}
85
+ const source = sources.find((file) => fsImpl.existsSync(file));
86
+ if (!source) return null;
87
+ try {
88
+ fsImpl.mkdirSync(path.dirname(configPath), { recursive: true });
89
+ fsImpl.copyFileSync(source, configPath, fs.constants.COPYFILE_EXCL);
90
+ return source;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
50
96
  export function loadTokenFromConfig(configPath) {
51
97
  try {
52
98
  const cfg = loadRelayConfig(configPath);
@@ -99,7 +99,9 @@ export function createRelayPromptBuilder({
99
99
  // Guidance is advisory: a preview lookup that fails must cost the block,
100
100
  // never the turn.
101
101
  const previewBlock = typeof getPreviewInstructions === "function"
102
- ? await Promise.resolve().then(getPreviewInstructions).catch(() => "")
102
+ ? await Promise.resolve()
103
+ .then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || "") }))
104
+ .catch(() => "")
103
105
  : "";
104
106
  instructions = applyPreviewInstructions(toolInstructions, previewBlock);
105
107
  }
@@ -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 {}
@@ -0,0 +1,65 @@
1
+ // The session's working directory, checked before a runtime is started in it.
2
+ //
3
+ // A session runs in the folder it was given (COPILOT_WORKSPACE_ROOT). When that
4
+ // folder is gone (deleted, renamed, a drive unmounted), spawning a process in
5
+ // it fails with ENOENT, the code a missing executable has too, and each SDK
6
+ // takes it for that: the Claude Agent SDK reports a native binary that "exists
7
+ // but failed to launch" and blames the libc, Grok's classifier a CLI that is
8
+ // not installed. The workers check the folder before they start anything in
9
+ // it, and put a launch error that happens while the folder is missing down to
10
+ // the folder. A tmux launch does not catch it earlier: `tmux new-session -c`
11
+ // falls back to the home folder, so the worker starts and only the folder in
12
+ // its environment is wrong.
13
+
14
+ import fs from 'node:fs';
15
+
16
+ export const WORKING_DIRECTORY_MISSING_CODE = 'working-directory-missing';
17
+
18
+ export const WORKING_DIRECTORY_GUIDANCE =
19
+ 'Restore the folder, or choose another working directory for this conversation (⋯ → Change CWD), '
20
+ + 'then send the message again.';
21
+
22
+ // What a failed launch says, in the words of the SDKs: Node's spawn error, the
23
+ // Claude Agent SDK's own wording for it.
24
+ const LAUNCH_FAILURE_TEXT = /\bENOENT\b|\bspawn\b|failed to launch|failed to spawn|not found at/i;
25
+
26
+ /**
27
+ * The error a turn fails with when `cwd` cannot be worked in, or null when it
28
+ * can. Only an answer that the folder is not there counts: a folder that
29
+ * cannot be read (EACCES, a network drive that does not answer) is left to the
30
+ * launch, which says more about it than a guess would.
31
+ */
32
+ export function missingWorkingDirectoryError(cwd, { statSync = fs.statSync } = {}) {
33
+ const folder = String(cwd || '').trim();
34
+ if (!folder) return null;
35
+ let stats = null;
36
+ try {
37
+ stats = statSync(folder);
38
+ } catch (error) {
39
+ if (error?.code !== 'ENOENT' && error?.code !== 'ENOTDIR') return null;
40
+ }
41
+ if (stats?.isDirectory()) return null;
42
+ const error = new Error(stats
43
+ ? `The working directory of this conversation is not a folder: ${folder}.`
44
+ : `The working directory of this conversation no longer exists: ${folder}.`);
45
+ error.code = WORKING_DIRECTORY_MISSING_CODE;
46
+ error.workingDirectory = folder;
47
+ error.guidance = WORKING_DIRECTORY_GUIDANCE;
48
+ return error;
49
+ }
50
+
51
+ export function isWorkingDirectoryMissingError(error) {
52
+ return error?.code === WORKING_DIRECTORY_MISSING_CODE;
53
+ }
54
+
55
+ /**
56
+ * A launch that failed while the folder is missing failed because of it: the
57
+ * folder's error in its place. Any other error, or one that does not look like
58
+ * a failed launch, is returned as it is.
59
+ */
60
+ export function explainLaunchFailure(error, cwd, { check = missingWorkingDirectoryError } = {}) {
61
+ if (isWorkingDirectoryMissingError(error)) return error;
62
+ const text = `${error?.code || ''} ${error?.message || error || ''}`;
63
+ if (!LAUNCH_FAILURE_TEXT.test(text)) return error;
64
+ return check(cwd) || error;
65
+ }