@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
@@ -1,6 +1,128 @@
1
1
  import fs from "fs";
2
+ import os from "os";
2
3
  import path from "path";
3
4
  import crypto from "crypto";
5
+ import { spawnSync } from "child_process";
6
+ import { createRequire } from "module";
7
+
8
+ // An EPERM is a process this user may not signal: alive.
9
+ export function defaultIsProcessAlive(pid) {
10
+ if (!Number.isInteger(pid) || pid <= 0) return false;
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ } catch (error) {
15
+ return error?.code === "EPERM";
16
+ }
17
+ }
18
+
19
+ let processTimes;
20
+ // When a process started, in ms, or null when that cannot be told. Windows
21
+ // asks the kernel (GetProcessTimes through koffi, which the Copilot SDK
22
+ // brings): PowerShell fails in ConstrainedLanguage mode and with a broken
23
+ // TEMP. Elsewhere `ps`.
24
+ export function processStartedAt(pid, { platform = process.platform } = {}) {
25
+ try {
26
+ if (platform !== "win32") {
27
+ // `lstart`, the full date; LC_ALL=C to parse; unparsable: cannot tell.
28
+ const result = spawnSync("ps", ["-o", "lstart=", "-p", String(Number(pid))], { encoding: "utf8", timeout: 5000, env: { ...process.env, LC_ALL: "C" } });
29
+ const ms = Date.parse(String(result.stdout || "").trim());
30
+ return Number.isFinite(ms) ? ms : null;
31
+ }
32
+ if (!processTimes) {
33
+ const koffi = createRequire(import.meta.url)("koffi");
34
+ const kernel32 = koffi.load("kernel32.dll");
35
+ processTimes = {
36
+ open: kernel32.func("void * __stdcall OpenProcess(uint32_t access, bool inherit, uint32_t pid)"),
37
+ times: kernel32.func("bool __stdcall GetProcessTimes(void *h, _Out_ uint64_t *a, _Out_ uint64_t *b, _Out_ uint64_t *c, _Out_ uint64_t *d)"),
38
+ close: kernel32.func("bool __stdcall CloseHandle(void *h)"),
39
+ };
40
+ }
41
+ const handle = processTimes.open(0x1000, false, Number(pid)); // PROCESS_QUERY_LIMITED_INFORMATION
42
+ if (!handle) return null;
43
+ try {
44
+ const created = [0n];
45
+ if (!processTimes.times(handle, created, [0n], [0n], [0n])) return null;
46
+ return Number(BigInt(created[0]) / 10000n) - 11644473600000; // FILETIME: 100 ns since 1601
47
+ } finally {
48
+ processTimes.close(handle);
49
+ }
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ let bootId;
56
+ // Linux's id of the running boot, '' elsewhere or when unreadable.
57
+ export function currentBootId({ platform = process.platform, readFile = fs.readFileSync } = {}) {
58
+ if (platform !== "linux") return "";
59
+ try {
60
+ bootId ??= String(readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
61
+ return bootId;
62
+ } catch {
63
+ return "";
64
+ }
65
+ }
66
+
67
+ /**
68
+ * A process's start as the system keeps it, which no later clock step moves:
69
+ * Windows' creation time (exact), Linux's start in ticks since boot with the
70
+ * boot id, elsewhere `ps` (to the second). Null when it cannot be told.
71
+ */
72
+ export function processIdentity(pid, { platform = process.platform, readFile = fs.readFileSync } = {}) {
73
+ try {
74
+ if (platform === "linux") {
75
+ const stat = String(readFile(`/proc/${Number(pid)}/stat`, "utf8"));
76
+ const ticks = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19]; // field 22, starttime
77
+ return /^\d+$/.test(ticks) ? { start: `ticks:${ticks}`, bootId: currentBootId({ platform, readFile }) } : null;
78
+ }
79
+ const ms = processStartedAt(pid, { platform });
80
+ if (ms === null) return null;
81
+ return { start: platform === "win32" ? `win:${ms}` : ms };
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // Exact for the kernel's own values, to the second for `ps`.
88
+ function sameStart(a, b) {
89
+ return typeof a === "number" && typeof b === "number" ? Math.abs(a - b) <= 1000 : a === b;
90
+ }
91
+
92
+ /**
93
+ * Whether the relay a lock names may still run; true when that cannot be
94
+ * told: better a refused start than two relays. A lock this version wrote
95
+ * (format 2) never goes by the clock: a live pid (EPERM counts) holds it
96
+ * when its start matches the recorded holder, or when either start is
97
+ * unknown; it is stale when the pid's start differs, or when the boot id it
98
+ * recorded (Linux) is not the running one. Only a lock without the format
99
+ * (0.9.13's) goes by the clock, the one case a clock step can mislead:
100
+ * written before the last boot, or older than the pid's process by more
101
+ * than 5 s (a reused pid), it is stale.
102
+ */
103
+ export function lockHolderMayRun({
104
+ lock,
105
+ isProcessAlive = defaultIsProcessAlive,
106
+ identityOf = processIdentity,
107
+ bootIdOf = currentBootId,
108
+ startedAtOf = processStartedAt,
109
+ bootTime = Date.now() - os.uptime() * 1000,
110
+ }) {
111
+ const pid = Number.parseInt(String(lock?.pid ?? ""), 10);
112
+ if (!Number.isInteger(pid) || pid <= 0 || !isProcessAlive(pid)) return false;
113
+ if (lock?.format >= 2 || lock?.holder) {
114
+ const current = lock.holder ? identityOf(pid) : null;
115
+ const recordedBoot = lock.holder?.bootId || lock.bootId;
116
+ const runningBoot = current?.bootId || bootIdOf();
117
+ if (recordedBoot && runningBoot && recordedBoot !== runningBoot) return false;
118
+ return !current || sameStart(lock.holder.start, current.start);
119
+ }
120
+ const lockedAt = Date.parse(String(lock?.startedAt || ""));
121
+ if (Number.isFinite(lockedAt) && lockedAt < bootTime) return false;
122
+ const startedAt = startedAtOf(pid);
123
+ if (Number.isFinite(lockedAt) && startedAt !== null && startedAt > lockedAt + 5000) return false;
124
+ return true;
125
+ }
4
126
 
5
127
  function hashToken(value) {
6
128
  const token = String(value || "").trim();
@@ -21,6 +143,9 @@ function safeReadLock(lockPath) {
21
143
  pid: Number.parseInt(String(parsed?.pid ?? ""), 10),
22
144
  tokenHash,
23
145
  startedAt: typeof parsed?.startedAt === "string" ? parsed.startedAt : null,
146
+ holder: parsed?.holder && typeof parsed.holder === "object" ? parsed.holder : null,
147
+ format: Number(parsed?.format) || 0,
148
+ bootId: typeof parsed?.bootId === "string" ? parsed.bootId : "",
24
149
  };
25
150
  } catch {
26
151
  return null;
@@ -33,23 +158,27 @@ export function createRelaySingletonGuard({
33
158
  token,
34
159
  now = () => new Date().toISOString(),
35
160
  isProcessAlive,
161
+ // When a pid's process started, and when the machine booted (lockHolderMayRun).
162
+ identityOf = processIdentity,
163
+ bootIdOf = currentBootId,
164
+ startedAtOf = processStartedAt,
165
+ bootTime = Date.now() - os.uptime() * 1000,
166
+ // An unreadable lock is read again for `readRetryMs`; still unreadable,
167
+ // it is stale only when its time is more than `unreadableStaleMs` off.
168
+ readRetryMs = 2000,
169
+ unreadableStaleMs = 10_000,
170
+ clock = Date.now,
171
+ sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
36
172
  logger = console,
37
173
  }) {
38
174
  const lockFilePath = path.resolve(String(lockPath || ""));
39
175
  const currentPid = Number.parseInt(String(pid || process.pid), 10);
40
176
  const currentTokenHash = hashToken(token);
41
- const processAlive = typeof isProcessAlive === "function"
42
- ? isProcessAlive
43
- : (candidatePid) => {
44
- if (!Number.isInteger(candidatePid) || candidatePid <= 0) return false;
45
- try {
46
- process.kill(candidatePid, 0);
47
- return true;
48
- } catch {
49
- return false;
50
- }
51
- };
177
+ const processAlive = typeof isProcessAlive === "function" ? isProcessAlive : defaultIsProcessAlive;
52
178
 
179
+ // The whole lock goes into a file of its own, linked into place (fails
180
+ // when a lock is there): no start ever reads one half written. Where the
181
+ // folder takes no hard links (FAT, exFAT), created and written in place.
53
182
  function writeLock() {
54
183
  fs.mkdirSync(path.dirname(lockFilePath), { recursive: true });
55
184
  const payload = {
@@ -59,47 +188,182 @@ export function createRelaySingletonGuard({
59
188
  // credential out of a file that has historically been world-readable.
60
189
  tokenHash: currentTokenHash,
61
190
  startedAt: now(),
191
+ // The holder, as lockHolderMayRun tells it without the clock (null:
192
+ // unknown, which then holds the lock while its pid lives).
193
+ format: 2,
194
+ holder: identityOf(currentPid) || null,
195
+ ...(bootIdOf() ? { bootId: bootIdOf() } : {}),
62
196
  };
63
- const fd = fs.openSync(lockFilePath, "wx", 0o600);
197
+ const text = `${JSON.stringify(payload, null, 2)}\n`;
198
+ const staged = `${lockFilePath}.${currentPid}-${crypto.randomBytes(4).toString("hex")}.tmp`;
199
+ let linked = false;
200
+ let written = false;
64
201
  try {
65
- fs.writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
202
+ fs.writeFileSync(staged, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
203
+ written = true;
204
+ fs.linkSync(staged, lockFilePath);
205
+ linked = true;
206
+ } catch (error) {
207
+ if (written && error?.code === "EEXIST") throw error;
66
208
  } finally {
67
- try { fs.closeSync(fd); } catch {}
209
+ if (written) try { fs.unlinkSync(staged); } catch {}
210
+ }
211
+ if (!linked) {
212
+ const fd = fs.openSync(lockFilePath, "wx", 0o600);
213
+ try {
214
+ fs.writeFileSync(fd, text, "utf8");
215
+ } finally {
216
+ try { fs.closeSync(fd); } catch {}
217
+ }
68
218
  }
69
219
  return payload;
70
220
  }
71
221
 
72
- function acquire() {
222
+ // Read again for a while (an antivirus scan, a lock written in place);
223
+ // still unreadable: stale when its time is far off, else held.
224
+ function readLockWithRetry() {
225
+ const deadline = clock() + readRetryMs;
226
+ for (;;) {
227
+ const lock = safeReadLock(lockFilePath);
228
+ if (lock || clock() >= deadline) return lock;
229
+ sleep(100);
230
+ }
231
+ }
232
+ function unreadableLockIsStale() {
73
233
  try {
74
- return writeLock();
75
- } catch (error) {
76
- if (error?.code !== "EEXIST") {
77
- throw error;
78
- }
234
+ return Math.abs(clock() - fs.statSync(lockFilePath).mtimeMs) > unreadableStaleMs;
235
+ } catch {
236
+ return true;
79
237
  }
238
+ }
80
239
 
81
- const existingLock = safeReadLock(lockFilePath);
82
- const existingPid = Number.isInteger(existingLock?.pid) ? existingLock.pid : null;
83
- const existingTokenHash = typeof existingLock?.tokenHash === "string" ? existingLock.tokenHash : "";
84
- const existingStartedAt = existingLock?.startedAt || "unknown";
240
+ const held = (message) => Object.assign(new Error(message), { code: "ERELAYLOCKED" });
241
+ const RENAME_RETRY_MS = [50, 100, 200, 400, 800];
85
242
 
86
- if (existingPid && processAlive(existingPid)) {
87
- if (existingTokenHash && existingTokenHash === currentTokenHash) {
88
- throw new Error(`Relay already running (pid=${existingPid}, startedAt=${existingStartedAt}).`);
89
- }
90
- throw new Error(
91
- `Relay appears to be owned by another live process (pid=${existingPid}, startedAt=${existingStartedAt}).`
92
- );
243
+ // The lock as judged: its fields, or for one that cannot be read its size
244
+ // and time, which a moved-aside file must still have to be the same lock.
245
+ function sameLock(file, judged) {
246
+ try {
247
+ const lock = safeReadLock(file);
248
+ if (judged.lock) return !!lock && ["pid", "tokenHash", "startedAt"].every((key) => Object.is(lock[key], judged.lock[key]));
249
+ const stat = fs.statSync(file);
250
+ return !lock && stat.size === judged.size && stat.mtimeMs === judged.mtimeMs;
251
+ } catch {
252
+ return false;
93
253
  }
254
+ }
94
255
 
256
+ // A lock put back after it turned out fresh: linked, else (no hard links)
257
+ // created with its content; a lock there by then wins, the file stays.
258
+ function putBack(moved) {
95
259
  try {
96
- fs.unlinkSync(lockFilePath);
97
- logger?.warn?.(`[server] Recovered stale singleton lock: ${lockFilePath}`);
98
- } catch (unlinkError) {
99
- if (unlinkError?.code !== "ENOENT") throw unlinkError;
260
+ try {
261
+ fs.linkSync(moved, lockFilePath);
262
+ } catch (error) {
263
+ if (error?.code === "EEXIST") throw error;
264
+ fs.writeFileSync(lockFilePath, fs.readFileSync(moved), { flag: "wx", mode: 0o600 });
265
+ }
266
+ fs.unlinkSync(moved);
267
+ } catch (error) {
268
+ logger?.warn?.(`[server] A relay lock moved aside could not be put back (${error?.code || error}); it is in ${moved}.`);
100
269
  }
270
+ }
101
271
 
102
- return writeLock();
272
+ // Files a crashed recovery or write left beside the lock (`.stale-<pid>-`,
273
+ // `.<pid>-….tmp`), never read: removed once their process is gone.
274
+ function removeLeftovers() {
275
+ const base = path.basename(lockFilePath);
276
+ let names = [];
277
+ try { names = fs.readdirSync(path.dirname(lockFilePath)); } catch {}
278
+ for (const name of names) {
279
+ if (!name.startsWith(`${base}.`)) continue;
280
+ const leftPid = Number(/^\.(?:stale-)?(\d+)-[0-9a-f]+(?:\.tmp)?$/.exec(name.slice(base.length))?.[1]);
281
+ if (leftPid && leftPid !== currentPid && !processAlive(leftPid)) try { fs.unlinkSync(path.join(path.dirname(lockFilePath), name)); } catch {}
282
+ }
283
+ }
284
+
285
+ function acquire() {
286
+ const payload = takeLock();
287
+ removeLeftovers();
288
+ return payload;
289
+ }
290
+
291
+ // A stale lock is moved aside, never unlinked unseen: the file moved must
292
+ // be the one judged, else another start took the lock meanwhile and it
293
+ // goes back. Gone already (another start recovered it): from the top, at
294
+ // most three times.
295
+ function takeLock() {
296
+ for (let attempt = 1; ; attempt += 1) {
297
+ try {
298
+ return writeLock();
299
+ } catch (error) {
300
+ if (error?.code !== "EEXIST") throw error;
301
+ }
302
+
303
+ const existingLock = readLockWithRetry();
304
+ let judged = { lock: existingLock };
305
+ if (!existingLock) {
306
+ if (!unreadableLockIsStale()) {
307
+ throw held(`A relay lock that cannot be read yet is there, written in the last ${unreadableStaleMs / 1000} s (lock ${lockFilePath}). If no relay is starting, delete it.`);
308
+ }
309
+ try {
310
+ const stat = fs.statSync(lockFilePath);
311
+ judged = { lock: null, size: stat.size, mtimeMs: stat.mtimeMs };
312
+ } catch {
313
+ if (attempt < 3) continue;
314
+ throw held(`The relay lock ${lockFilePath} keeps changing.`);
315
+ }
316
+ }
317
+ const existingPid = Number.isInteger(existingLock?.pid) ? existingLock.pid : null;
318
+ const existingTokenHash = typeof existingLock?.tokenHash === "string" ? existingLock.tokenHash : "";
319
+ const existingStartedAt = existingLock?.startedAt || "unknown";
320
+
321
+ // A lock from before the last boot, or whose pid now belongs to a
322
+ // process started after it, is stale (a crash, a power loss, a forced
323
+ // reboot): recovered below.
324
+ if (existingPid && lockHolderMayRun({ lock: existingLock, isProcessAlive: processAlive, identityOf, bootIdOf, startedAtOf, bootTime })) {
325
+ const owner = existingTokenHash && existingTokenHash === currentTokenHash
326
+ ? "Relay already running"
327
+ : "Relay appears to be owned by another live process";
328
+ // Delete the lock only when its holder cannot be told to be a relay.
329
+ const known = existingLock.holder && identityOf(existingPid);
330
+ throw held(`${owner} (pid=${existingPid}, startedAt=${existingStartedAt}; lock ${lockFilePath}). ${known
331
+ ? `It is held by relay process ${existingPid}: oar stop, or end process ${existingPid}.`
332
+ : `If no relay is running, end process ${existingPid} or delete the lock.`}`);
333
+ }
334
+
335
+ const moved = `${lockFilePath}.stale-${currentPid}-${crypto.randomBytes(4).toString("hex")}`;
336
+ for (let retry = 0; ; retry += 1) {
337
+ try {
338
+ fs.renameSync(lockFilePath, moved);
339
+ break;
340
+ } catch (error) {
341
+ if (error?.code === "ENOENT") break;
342
+ // Held open (an antivirus scan): briefly, then counted as held.
343
+ if (retry < RENAME_RETRY_MS.length && ["EBUSY", "EPERM", "EACCES"].includes(error?.code)) {
344
+ sleep(RENAME_RETRY_MS[retry]);
345
+ continue;
346
+ }
347
+ throw held(`The stale relay lock ${lockFilePath} could not be moved aside (${error?.code || error}); delete it if no relay is running.`);
348
+ }
349
+ }
350
+ if (!fs.existsSync(moved)) {
351
+ if (attempt < 3) continue;
352
+ throw held(`The relay lock ${lockFilePath} keeps changing.`);
353
+ }
354
+ if (!sameLock(moved, judged)) {
355
+ putBack(moved);
356
+ throw held(`Another relay took the lock ${lockFilePath} while this one recovered it.`);
357
+ }
358
+ try { fs.unlinkSync(moved); } catch {}
359
+ logger?.warn?.(`[server] Recovered stale singleton lock: ${lockFilePath}`);
360
+ try {
361
+ return writeLock();
362
+ } catch (error) {
363
+ if (error?.code !== "EEXIST") throw error;
364
+ if (attempt >= 3) throw held(`The relay lock ${lockFilePath} keeps changing.`);
365
+ }
366
+ }
103
367
  }
104
368
 
105
369
  function release() {
@@ -8,6 +8,7 @@ import {
8
8
  REMOTE_RELAY_PROVIDERS,
9
9
  } from '../../shared/remote-relay-contract.mjs';
10
10
  import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../../shared/question-timeout.mjs';
11
+ import { readApprovalAnswer } from '../../shared/question-answer-rule.mjs';
11
12
  import { createQuestionRepository } from '../repositories/question-repository.mjs';
12
13
  import { pickLiveTurnRowId } from './live-turn-picker.mjs';
13
14
  import { sanitizeRelayQuestionContext } from './relay-question-context.mjs';
@@ -427,8 +428,12 @@ export function createRemoteRelayCallerContext({
427
428
  const current = questionStatements.getQuestion.get(questionId);
428
429
  if (!current) return { approved: false, error: 'The approval question was removed.' };
429
430
  if (current.status === 'answered') {
430
- const approved = toText(current.answer).toLowerCase() === APPROVAL_ALLOW.toLowerCase();
431
- return approved ? { approved: true } : { approved: false };
431
+ const decision = readApprovalAnswer(current.answer, { approve: APPROVAL_ALLOW, deny: APPROVAL_DENY });
432
+ if (decision === 'approve') return { approved: true };
433
+ if (decision === 'deny') return { approved: false };
434
+ // The answer route takes only the choices; anything else in the row
435
+ // is no decision of the user's, so it is not passed off as their Deny.
436
+ return { approved: false, error: 'The approval question was answered with neither Allow nor Deny. Go on without it, or ask the user first.' };
432
437
  }
433
438
  if (current.status !== 'pending') {
434
439
  return {