@oar-sh/oar 0.9.13 → 0.9.15

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 (74) 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 +396 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +677 -103
  7. package/package.json +7 -5
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  9. package/server/claude-worker/claude-session-process.mjs +39 -13
  10. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  12. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  13. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  14. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  17. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  19. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  20. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  21. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  22. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  23. package/server/public/app/api-client.js +3 -1
  24. package/server/public/app/ask-user-view.js +29 -4
  25. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  26. package/server/public/app/question-answer-rule.mjs +15 -0
  27. package/server/public/app/question-multi-select.mjs +9 -2
  28. package/server/public/app/relay-event-view.mjs +5 -1
  29. package/server/public/app/settings-modal.js +17 -3
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +49 -16
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +18 -5
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +415 -45
  61. package/server/services/windows-boot-autostart-service.mjs +962 -185
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/.github/extensions/web-relay/README.md +0 -190
  73. package/server/README.md +0 -2187
  74. 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 {
@@ -25,8 +25,9 @@ import {
25
25
  withRemotePromptHeader,
26
26
  } from '../../shared/remote-relay-contract.mjs';
27
27
  import { schemaFields } from '../../shared/question-schema.mjs';
28
+ import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
28
29
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
29
- import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
30
+ import { RELAY_EVENT_DEFAULT_TYPES, RELAY_EVENT_RELAY_LEVEL_TYPES } from '../../shared/relay-events.mjs';
30
31
  import { checkRequestedProviderModel, isAutoModelId, providerModelLabel, unknownProviderModelText } from './provider-model-selection.mjs';
31
32
  import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
32
33
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
@@ -2132,7 +2133,14 @@ export function createRemoteRelayDispatcher({
2132
2133
  if (!labels.includes(label)) labels.push(label);
2133
2134
  }
2134
2135
  // The card's own format: the picked labels, then any typed text, ", "-joined.
2135
- const extra = toText(args.answer);
2136
+ // A card that takes no typed text gets an option or nothing.
2137
+ let extra = toText(args.answer);
2138
+ if (extra && question.allowFreeform === false) {
2139
+ extra = matchQuestionChoice(extra, options);
2140
+ if (!extra) {
2141
+ throw invalidInput(`This question takes only its options (${options.join(', ')}), no typed answer; pass one of them in choices.`);
2142
+ }
2143
+ }
2136
2144
  if (extra && !labels.includes(extra)) labels.push(extra);
2137
2145
  const text = labels.join(', ');
2138
2146
  return { answer: text, text };
@@ -2332,7 +2340,7 @@ export function createRemoteRelayDispatcher({
2332
2340
  if (!ctx.relay.local) return subscribeRemoteAction(ctx, args);
2333
2341
  const service = eventService(ctx);
2334
2342
  const all = args.scope === 'all';
2335
- const ids = all ? [] : args.sessions;
2343
+ const ids = all ? [] : (args.sessions || []);
2336
2344
  // Refused before anyone is asked: the conversation itself, unknown ids.
2337
2345
  if (!all && ids.includes(ctx.conversationId)) {
2338
2346
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
@@ -2349,11 +2357,15 @@ export function createRemoteRelayDispatcher({
2349
2357
  titles.push({ id, title: toText(page.title) || null });
2350
2358
  }
2351
2359
  const events = Array.isArray(args.events) && args.events.length ? args.events : [...RELAY_EVENT_DEFAULT_TYPES];
2352
- const decision = await ensureWatchApproval(ctx, {
2353
- all,
2354
- watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
2355
- });
2356
- if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2360
+ // Only relay_restarted, no session: nothing of another conversation is
2361
+ // watched, so there is nothing to ask the user about.
2362
+ if (all || ids.length) {
2363
+ const decision = await ensureWatchApproval(ctx, {
2364
+ all,
2365
+ watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
2366
+ });
2367
+ if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2368
+ }
2357
2369
  const subscription = relayEventCall(() => service.subscribe({
2358
2370
  watcherConversationId: ctx.conversationId,
2359
2371
  scope: all ? 'all' : 'sessions',
@@ -2364,8 +2376,9 @@ export function createRemoteRelayDispatcher({
2364
2376
  stalledMinutes: args.stalled_minutes ?? null,
2365
2377
  heartbeatMinutes: args.heartbeat_minutes ?? null,
2366
2378
  }));
2379
+ const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'relay_restarted');
2367
2380
  return {
2368
- summary: `subscribe → ${ctx.relay.name}: ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${subscription.id})`,
2381
+ summary: `subscribe → ${ctx.relay.name}: ${what} (${subscription.id})`,
2369
2382
  subscription,
2370
2383
  note: EVENT_NOTE,
2371
2384
  };
@@ -2378,6 +2391,14 @@ export function createRemoteRelayDispatcher({
2378
2391
  * mirror; the link fetches the events from then on.
2379
2392
  */
2380
2393
  async function subscribeRemoteAction(ctx, args) {
2394
+ const relayLevel = (args.events || []).filter((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
2395
+ if (relayLevel.length) {
2396
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2397
+ `${relayLevel.join(', ')} is about the relay you run on: subscribe to it on "${REMOTE_RELAY_LOCAL_ALIAS}". An outage of relay "${ctx.relay.name}" shows as link_lost and link_restored.`);
2398
+ }
2399
+ if (!args.sessions?.length && args.scope !== 'all') {
2400
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'subscribe on a paired relay needs sessions (ids from list_sessions) or ["all"].');
2401
+ }
2381
2402
  const service = eventService(ctx);
2382
2403
  const all = args.scope === 'all';
2383
2404
  relayEventCall(() => service.checkWatcherCapacity(ctx.conversationId));
@@ -25,7 +25,9 @@
25
25
  * - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
26
26
  * a new task id is a start, a task gone from the set has ended, a changed
27
27
  * task (tool call, summary, tokens) is activity. A task that started
28
- * before this runtime did was announced by the runtime before it.
28
+ * before this runtime did was announced by the runtime before it. A task
29
+ * that ended and is listed under another run (`taskRunId`: a resumed
30
+ * Claude sub-agent keeps its task id) is a start again.
29
31
  * - `noteTaskSettled` — how a task ended, where the worker says so (the
30
32
  * Claude worker's task_notification). A task this runtime never knew
31
33
  * still gets its end event when the worker describes it.
@@ -168,6 +170,11 @@ export function createSessionActivityTracker({
168
170
  const sessions = new Map();
169
171
  // Edges already reported for an end, by `${conversationId}:${key}`.
170
172
  const endedKeys = new Map();
173
+ // `${conversationId}:${taskId}` → the run of the task that ended (the
174
+ // SDK's run id, `taskRunId`), where the worker names one. A resumed Claude
175
+ // sub-agent keeps its task id and runs again under a new run id: a set
176
+ // that lists it with another run is a new start, not a late set.
177
+ const endedRuns = new Map();
171
178
  // `${conversationId}:${taskId}` of the ends nobody reported: the task was
172
179
  // only missing from a set for the grace. A later set that lists it again
173
180
  // says it never ended.
@@ -216,16 +223,25 @@ export function createSessionActivityTracker({
216
223
  for (const key of [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']) {
217
224
  if (key) endedKeys.set(`${conversationId}:${key}`, at);
218
225
  }
226
+ if (task.taskRunId) endedRuns.set(`${conversationId}:${task.taskId}`, task.taskRunId);
227
+ else endedRuns.delete(`${conversationId}:${task.taskId}`);
219
228
  if (endedKeys.size > 2000) {
220
229
  for (const [key, when] of endedKeys) {
221
230
  if (at - when > RECENT_TASK_TTL_MS) {
222
231
  endedKeys.delete(key);
223
232
  endedByAbsence.delete(key);
233
+ endedRuns.delete(key);
224
234
  }
225
235
  }
226
236
  }
227
237
  }
228
238
 
239
+ /** The set lists an ended task under another run: it was resumed. */
240
+ function isNewRun(conversationId, taskId, taskRunId) {
241
+ const ended = endedRuns.get(`${conversationId}:${taskId}`);
242
+ return !!taskRunId && !!ended && ended !== taskRunId;
243
+ }
244
+
229
245
  function wasEnded(conversationId, task) {
230
246
  return [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']
231
247
  .some((key) => key && endedKeys.has(`${conversationId}:${key}`));
@@ -308,6 +324,7 @@ export function createSessionActivityTracker({
308
324
  /** Takes back an end that was only an absence; the task's old row goes from the recent ones. */
309
325
  function unEnd(conversationId, entry, taskId, toolUseId) {
310
326
  endedByAbsence.delete(`${conversationId}:${taskId}`);
327
+ endedRuns.delete(`${conversationId}:${taskId}`);
311
328
  const index = entry.recent.findIndex((task) => task.taskId === taskId);
312
329
  const [old] = index >= 0 ? entry.recent.splice(index, 1) : [null];
313
330
  for (const key of [taskId, toolUseId ? `run:${toolUseId}` : '', old?.toolUseId ? `run:${old.toolUseId}` : '']) {
@@ -332,6 +349,7 @@ export function createSessionActivityTracker({
332
349
  seen.add(taskId);
333
350
  const signature = taskSignature(raw);
334
351
  const known = entry.live.get(taskId);
352
+ const taskRunId = toText(raw.taskRunId);
335
353
  if (known && !known.leftAtMs) {
336
354
  if (known.signature !== signature) {
337
355
  known.signature = signature;
@@ -340,32 +358,45 @@ export function createSessionActivityTracker({
340
358
  }
341
359
  known.description = toText(raw.description) || known.description;
342
360
  known.toolUseId = toText(raw.toolUseId) || known.toolUseId;
361
+ known.taskRunId = taskRunId || known.taskRunId;
343
362
  known.summary = toText(raw.summary) || known.summary;
344
363
  known.model = toText(raw.model) || known.model;
345
364
  continue;
346
365
  }
347
366
  if (known?.leftAtMs) {
348
- // Back in the set after it left: it did not end after all.
349
- if (known.graceTimer) clearTimer(known.graceTimer);
350
- known.graceTimer = null;
351
- known.leftAtMs = 0;
352
- continue;
367
+ if (!taskRunId || !known.taskRunId || known.taskRunId === taskRunId) {
368
+ // Back in the set after it left: it did not end after all.
369
+ if (known.graceTimer) clearTimer(known.graceTimer);
370
+ known.graceTimer = null;
371
+ known.leftAtMs = 0;
372
+ continue;
373
+ }
374
+ // Back under another run (resumed within the grace): the run that
375
+ // left ended, how is not known; the new one starts below.
376
+ finishTask(id, known, { status: 'completed', reported: false });
353
377
  }
354
378
  let revived = null;
355
379
  if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) {
356
- // Settled already (its notice came before the set dropped it).
357
- if (!endedByAbsence.has(`${id}:${taskId}`)) continue;
358
- // Ended only because a set did not list it (a set that arrived out
359
- // of turn, a worker that left it out for a moment) and listed
360
- // again: it never ended. It comes back as the task it was, with no
361
- // second start. The end that was reported for it stays reported;
362
- // its real end will be another.
363
- revived = unEnd(id, entry, taskId, toText(raw.toolUseId)) || {};
380
+ if (isNewRun(id, taskId, taskRunId)) {
381
+ // Resumed: the same task, a new run. Its first run's end stays
382
+ // reported; this run starts (and will end) on its own.
383
+ unEnd(id, entry, taskId, toText(raw.toolUseId));
384
+ } else {
385
+ // Settled already (its notice came before the set dropped it).
386
+ if (!endedByAbsence.has(`${id}:${taskId}`)) continue;
387
+ // Ended only because a set did not list it (a set that arrived out
388
+ // of turn, a worker that left it out for a moment) and listed
389
+ // again: it never ended. It comes back as the task it was, with no
390
+ // second start. The end that was reported for it stays reported;
391
+ // its real end will be another.
392
+ revived = unEnd(id, entry, taskId, toText(raw.toolUseId)) || {};
393
+ }
364
394
  }
365
395
  const taskStartedAtMs = msOf(raw.startedAt);
366
396
  const task = {
367
397
  taskId,
368
398
  toolUseId: toText(raw.toolUseId) || null,
399
+ taskRunId: taskRunId || null,
369
400
  taskType: toText(raw.taskType),
370
401
  kind: taskKindOf(raw.taskType),
371
402
  description: toText(raw.description).slice(0, 500),
@@ -395,16 +426,21 @@ export function createSessionActivityTracker({
395
426
  }
396
427
 
397
428
  function noteTaskSettled(conversationId, {
398
- taskId, toolUseId, status, summary, exitCode, description, taskType, startedAt,
429
+ taskId, toolUseId, taskRunId, status, summary, exitCode, description, taskType, startedAt,
399
430
  } = {}) {
400
431
  const id = toText(conversationId);
401
432
  if (!id) return;
402
433
  const entry = session(id);
403
434
  const key = toText(taskId);
404
435
  const run = toText(toolUseId);
436
+ const settledRun = toText(taskRunId);
405
437
  const task = (key && entry.live.get(key))
406
438
  || [...entry.live.values()].find((candidate) => run && candidate.toolUseId === run)
407
439
  || null;
440
+ // The end of an earlier run of a task that was resumed since (a notice
441
+ // the worker sent again late): the run that is live now goes on.
442
+ if (task && settledRun && task.taskRunId && task.taskRunId !== settledRun) return;
443
+ if (task && settledRun) task.taskRunId = settledRun;
408
444
  const settled = {
409
445
  status: normalizeTaskStatus(status || 'completed'),
410
446
  summary: toText(summary) || null,
@@ -439,6 +475,7 @@ export function createSessionActivityTracker({
439
475
  const unknown = {
440
476
  taskId: key,
441
477
  toolUseId: runId || null,
478
+ taskRunId: settledRun || null,
442
479
  taskType: toText(taskType),
443
480
  kind: taskKindOf(taskType),
444
481
  description: toText(description).slice(0, 500),