@oar-sh/oar 0.9.13 → 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 (72) 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 +302 -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 +296 -149
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +39 -13
  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/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  14. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  15. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  16. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  17. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  18. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  19. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  20. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  21. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  22. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  24. package/server/public/app/api-client.js +3 -1
  25. package/server/public/app/ask-user-view.js +29 -4
  26. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  27. package/server/public/app/question-answer-rule.mjs +15 -0
  28. package/server/public/app/question-multi-select.mjs +9 -2
  29. package/server/public/app/relay-event-view.mjs +5 -1
  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 +4 -2
  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 +6 -1
  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 +329 -31
  61. package/server/services/windows-boot-autostart-service.mjs +188 -60
  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/server/uploads/image_input.md +0 -540
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { execFile, spawn } from 'child_process';
4
4
 
5
5
  import { resolveOarRoot } from './oar-state-migration-service.mjs';
6
+ import { resolveLayoutStateDirs, resolveRelayStateDirs } from './oar-state-layout.mjs';
6
7
  import {
7
8
  describeExecError,
8
9
  describeSpawnEnvironment,
@@ -11,8 +12,14 @@ import {
11
12
  import {
12
13
  LEGACY_WINDOWS_AUTOSTART_FILENAME,
13
14
  WINDOWS_AUTOSTART_FILENAME,
15
+ autostartWaitLines,
16
+ buildLauncherFile,
14
17
  escapeBatchValue,
18
+ launcherNodeLine,
19
+ launcherScriptsMatch,
20
+ readLauncherNodePath,
15
21
  resolveWindowsStartupDirectory,
22
+ stateDirLines,
16
23
  } from './windows-autostart-service.mjs';
17
24
 
18
25
  /**
@@ -48,7 +55,8 @@ export const BOOT_ELEVATION_SCRIPT_FILENAME = 'oar-relay-elevate.ps1';
48
55
  // Where a regenerated launcher keeps the one it replaced, when they differ.
49
56
  export const BOOT_LAUNCHER_BACKUP_SUFFIX = '.bak';
50
57
  const ELEVATION_TIMEOUT_MS = 120_000;
51
- export const BOOT_TEMP_WAIT_SECONDS = 120;
58
+ // Next to the launcher: a wait for TEMP and the data drive that ran out (see
59
+ // autostartWaitLines; AUTOSTART_WAIT_SECONDS is the limit).
52
60
  export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
53
61
  // Each schtasks query; the settings page waits on two of them.
54
62
  const QUERY_TIMEOUT_MS = 6_000;
@@ -69,6 +77,8 @@ function escapeXml(value) {
69
77
  /**
70
78
  * The launcher the task executes. Same shape as the sign-in .cmd minus the
71
79
  * `title` line: a boot task runs in session 0 where no console is ever shown.
80
+ * Like it, the launcher pins the config and the data and log folders `oar
81
+ * start` uses, so both start the same relay.
72
82
  *
73
83
  * Node's folder goes at the end of PATH. The relay itself runs on the
74
84
  * absolute path below, but a boot task's PATH can lack the user's Node (a
@@ -76,65 +86,142 @@ function escapeXml(value) {
76
86
  * and the CLIs and commands the workers start may call a bare `node`. At the
77
87
  * end it only matters when PATH has no Node of its own.
78
88
  *
79
- * Before Node starts, the launcher waits up to BOOT_TEMP_WAIT_SECONDS for the
80
- * %TEMP% folder: at boot it can live on a drive that a startup script creates
81
- * only after the task has fired, and Node, the workers and their CLIs then
82
- * fail creating their temp files. It costs nothing when the folder is there,
83
- * is skipped when TEMP is not set, and a wait that runs out is logged to
84
- * BOOT_LAUNCHER_LOG_FILENAME next to the launcher before Node starts anyway.
89
+ * Before Node starts it waits for %TEMP% and the data drive (autostartWaitLines),
90
+ * logging a wait that ran out to BOOT_LAUNCHER_LOG_FILENAME beside it.
85
91
  */
92
+ const BOOT_LAUNCHER_HEADER = [
93
+ '@echo off',
94
+ 'rem Generated by OAR settings (system-startup autostart). Regenerated on',
95
+ 'rem every settings change - do not edit by hand.',
96
+ ];
97
+
98
+ function bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl }) {
99
+ if (!normalizeText(packageRoot)) {
100
+ throw new Error('Windows boot autostart requires the package root.');
101
+ }
102
+ if (!normalizeText(nodePath)) {
103
+ throw new Error('Windows boot autostart requires the Node.js executable path.');
104
+ }
105
+ const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
106
+ return {
107
+ serverPath: pathImpl.join(resolvedPackageRoot, 'server', 'server.js'),
108
+ resolvedNodePath: pathImpl.resolve(nodePath),
109
+ cwdTarget: normalizeText(workingDirectory)
110
+ ? pathImpl.resolve(String(workingDirectory))
111
+ : resolvedPackageRoot,
112
+ };
113
+ }
114
+
115
+ function configLines(configPath, pathImpl) {
116
+ return normalizeText(configPath)
117
+ ? [`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`]
118
+ : [];
119
+ }
120
+
121
+ // 0.9.11 to 0.9.13's wait (%TEMP% only, 120 s), verbatim, so a refresh knows it.
122
+ const EARLIER_BOOT_TEMP_WAIT_LINES = [
123
+ 'if "%TEMP%"=="" goto oar_start',
124
+ 'set /a OAR_TEMP_WAIT=0',
125
+ ':oar_wait_temp',
126
+ 'if exist "%TEMP%\\" goto oar_start',
127
+ 'if %OAR_TEMP_WAIT% geq 120 goto oar_temp_missing',
128
+ '"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
129
+ 'set /a OAR_TEMP_WAIT+=1',
130
+ 'goto oar_wait_temp',
131
+ ':oar_temp_missing',
132
+ `echo [%DATE% %TIME%] "%TEMP%" still missing after 120 s; starting the relay anyway.>>"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`,
133
+ ':oar_start',
134
+ 'set "OAR_TEMP_WAIT="',
135
+ ];
136
+
137
+ // The boot launcher's lines after the padding, with the wait and the Node
138
+ // line's ending given (see launcherNodeLine).
139
+ function bootBodyLines({ resolvedNodePath, serverPath, cwdTarget, configPath, dataDir, logDir, pathImpl, waitLines, nodeLineOptions = {} }) {
140
+ return [
141
+ `set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
142
+ ...configLines(configPath, pathImpl),
143
+ ...stateDirLines({ dataDir, logDir, pathImpl }),
144
+ ...waitLines,
145
+ `cd /d "${escapeBatchValue(cwdTarget)}"`,
146
+ launcherNodeLine(resolvedNodePath, serverPath, nodeLineOptions),
147
+ ];
148
+ }
149
+
86
150
  export function buildBootLauncherScript({
87
151
  packageRoot,
88
152
  nodePath = process.execPath,
89
153
  configPath = '',
154
+ dataDir = '',
155
+ logDir = '',
90
156
  // Where the relay process lives, NOT where its code lives: on Windows a
91
157
  // process pins its cwd, and self-update must be able to replace the package
92
158
  // directory. Callers pass the OAR state root; the package-root default only
93
159
  // serves builders that predate self-update.
94
160
  workingDirectory = '',
161
+ // Size of the file this one replaces (see buildLauncherFile).
162
+ coverBytes = 0,
95
163
  pathImpl = path,
96
164
  } = {}) {
97
- if (!normalizeText(packageRoot)) {
98
- throw new Error('Windows boot autostart requires the package root.');
99
- }
100
- if (!normalizeText(nodePath)) {
101
- throw new Error('Windows boot autostart requires the Node.js executable path.');
102
- }
103
- const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
104
- const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
105
- const resolvedNodePath = pathImpl.resolve(nodePath);
106
- const lines = [
107
- '@echo off',
108
- 'rem Generated by OAR settings (system-startup autostart). Regenerated on',
109
- 'rem every settings change - do not edit by hand.',
110
- `set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
165
+ const { serverPath, resolvedNodePath, cwdTarget } = bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl });
166
+ return buildLauncherFile({
167
+ headerLines: BOOT_LAUNCHER_HEADER,
168
+ bodyLines: bootBodyLines({
169
+ resolvedNodePath,
170
+ serverPath,
171
+ cwdTarget,
172
+ configPath,
173
+ dataDir,
174
+ logDir,
175
+ pathImpl,
176
+ waitLines: autostartWaitLines({ dataDir, logTarget: `"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`, pathImpl }),
177
+ }),
178
+ coverBytes,
179
+ });
180
+ }
181
+
182
+ /**
183
+ * The launchers earlier versions generated, newest first: the padded one
184
+ * before 0.9.14 shipped (120 s TEMP wait, `& exit /b`; needs `dataDir` and
185
+ * `logDir`), 0.9.11 to 0.9.13, then 0.9.2 to 0.9.10.
186
+ */
187
+ export function buildEarlierBootLauncherScripts({
188
+ packageRoot,
189
+ nodePath,
190
+ configPath = '',
191
+ dataDir = '',
192
+ logDir = '',
193
+ workingDirectory = '',
194
+ pathImpl = path,
195
+ } = {}) {
196
+ const { serverPath, resolvedNodePath, cwdTarget } = bootLauncherInputs({ packageRoot, nodePath, workingDirectory, pathImpl });
197
+ const nodeLine = `"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`;
198
+ const cdLine = `cd /d "${escapeBatchValue(cwdTarget)}"`;
199
+ return [
200
+ buildLauncherFile({
201
+ headerLines: BOOT_LAUNCHER_HEADER,
202
+ bodyLines: bootBodyLines({
203
+ resolvedNodePath,
204
+ serverPath,
205
+ cwdTarget,
206
+ configPath,
207
+ dataDir,
208
+ logDir,
209
+ pathImpl,
210
+ waitLines: EARLIER_BOOT_TEMP_WAIT_LINES,
211
+ nodeLineOptions: { ending: 'exit /b' },
212
+ }),
213
+ }),
214
+ [
215
+ ...BOOT_LAUNCHER_HEADER,
216
+ `set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
217
+ ...configLines(configPath, pathImpl),
218
+ ...EARLIER_BOOT_TEMP_WAIT_LINES,
219
+ cdLine,
220
+ nodeLine,
221
+ '',
222
+ ].join('\r\n'),
223
+ [...BOOT_LAUNCHER_HEADER, ...configLines(configPath, pathImpl), cdLine, nodeLine, ''].join('\r\n'),
111
224
  ];
112
- if (normalizeText(configPath)) {
113
- lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
114
- }
115
- const cwdTarget = normalizeText(workingDirectory)
116
- ? pathImpl.resolve(String(workingDirectory))
117
- : resolvedPackageRoot;
118
- lines.push(
119
- // About one second a round: ping waits a second between its two echoes.
120
- // By full path, since the PATH of an early boot may not lead to System32.
121
- 'if "%TEMP%"=="" goto oar_start',
122
- 'set /a OAR_TEMP_WAIT=0',
123
- ':oar_wait_temp',
124
- 'if exist "%TEMP%\\" goto oar_start',
125
- `if %OAR_TEMP_WAIT% geq ${BOOT_TEMP_WAIT_SECONDS} goto oar_temp_missing`,
126
- '"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
127
- 'set /a OAR_TEMP_WAIT+=1',
128
- 'goto oar_wait_temp',
129
- ':oar_temp_missing',
130
- `echo [%DATE% %TIME%] "%TEMP%" still missing after ${BOOT_TEMP_WAIT_SECONDS} s; starting the relay anyway.>>"%~dp0${BOOT_LAUNCHER_LOG_FILENAME}"`,
131
- ':oar_start',
132
- 'set "OAR_TEMP_WAIT="',
133
- `cd /d "${escapeBatchValue(cwdTarget)}"`,
134
- `"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
135
- '',
136
- );
137
- return lines.join('\r\n');
138
225
  }
139
226
 
140
227
  function unescapeXml(value) {
@@ -273,15 +360,8 @@ export function describeTaskCustomisations(xmlText, { launcherPath, workingDirec
273
360
  return differences;
274
361
  }
275
362
 
276
- /** Same launcher, whatever the line endings and trailing blanks. */
277
- export function launcherScriptsMatch(actual, expected) {
278
- const normalize = (value) => String(value == null ? '' : value)
279
- .split(/\r?\n/)
280
- .map((line) => line.replace(/\s+$/, ''))
281
- .join('\n')
282
- .trim();
283
- return normalize(actual) === normalize(expected);
284
- }
363
+ // Shared with the sign-in entry; still exported from here for its callers.
364
+ export { launcherScriptsMatch };
285
365
 
286
366
  /**
287
367
  * Ours or someone else's? A task counts as ours when its action runs our
@@ -380,6 +460,7 @@ export function createWindowsBootAutostartService({
380
460
  const oarRoot = () => resolveOarRoot(env, platform);
381
461
  const launcherPath = () => pathImpl.join(oarRoot(), BOOT_LAUNCHER_FILENAME);
382
462
  const xmlPath = () => pathImpl.join(oarRoot(), BOOT_TASK_XML_FILENAME);
463
+ const serverPath = () => pathImpl.join(pathImpl.resolve(String(packageRoot || '')), 'server', 'server.js');
383
464
  const elevationScriptPath = () => pathImpl.join(oarRoot(), BOOT_ELEVATION_SCRIPT_FILENAME);
384
465
 
385
466
  function log(message) {
@@ -416,8 +497,49 @@ export function createWindowsBootAutostartService({
416
497
  }
417
498
  }
418
499
 
419
- function generatedLauncher() {
420
- return buildBootLauncherScript({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl });
500
+ function generatedLauncher({ stateDirs, coverBytes = 0, nodePathOverride = null } = {}) {
501
+ return buildBootLauncherScript({
502
+ packageRoot,
503
+ nodePath: nodePathOverride || nodePath,
504
+ configPath,
505
+ ...(stateDirs || resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl })),
506
+ workingDirectory: oarRoot(),
507
+ coverBytes,
508
+ pathImpl,
509
+ });
510
+ }
511
+
512
+ /**
513
+ * Brings a launcher an earlier version wrote (see
514
+ * buildEarlierBootLauncherScripts) up to date; the relay calls this once it
515
+ * runs. The task runs the launcher file, so this needs no elevation. Only a
516
+ * launcher exactly as that version generated it for this install and its
517
+ * config is rewritten, keeping the Node it runs; an edited one stays (it
518
+ * reads as customised, and enabling again replaces it, keeping a copy). It
519
+ * pins only the install's own folders (resolveLayoutStateDirs). True when
520
+ * it rewrote the launcher.
521
+ */
522
+ // `onEarlierForm`: the earlier form found (`pinnedData` false: package database).
523
+ function refreshInstalledLauncher({ onEarlierForm } = {}) {
524
+ if (!supported || spawnsDisabled()) return false;
525
+ const stateDirs = resolveLayoutStateDirs({ packageRoot, env, platform, fsImpl, pathImpl });
526
+ if (!stateDirs) return false;
527
+ const current = readLauncher();
528
+ if (current === null) return false;
529
+ const launcherNodePath = readLauncherNodePath(current, serverPath());
530
+ if (!launcherNodePath) return false;
531
+ const earlier = buildEarlierBootLauncherScripts({
532
+ packageRoot, nodePath: launcherNodePath, configPath, ...stateDirs, workingDirectory: oarRoot(), pathImpl,
533
+ });
534
+ const matched = earlier.find((script) => launcherScriptsMatch(current, script, { ignoreCase: true }));
535
+ if (!matched) return false;
536
+ onEarlierForm?.({ path: launcherPath(), pinnedData: matched.includes('COPILOT_WEB_RELAY_DATA_DIR') });
537
+ writeFileAtomic(launcherPath(), generatedLauncher({
538
+ stateDirs,
539
+ nodePathOverride: launcherNodePath,
540
+ coverBytes: Buffer.byteLength(current, 'utf8'),
541
+ }));
542
+ return true;
421
543
  }
422
544
 
423
545
  /** What differs from the generated task and launcher, for a task that runs our launcher. */
@@ -430,7 +552,12 @@ export function createWindowsBootAutostartService({
430
552
  if (launcher === null) differences.push('launcher-missing');
431
553
  else {
432
554
  try {
433
- if (!launcherScriptsMatch(launcher, generatedLauncher())) differences.push('launcher');
555
+ // Against the Node the launcher runs, as the repair keeps it: a moved
556
+ // Node is no customisation.
557
+ const ownNodePath = readLauncherNodePath(launcher, serverPath());
558
+ if (!launcherScriptsMatch(launcher, generatedLauncher({ nodePathOverride: ownNodePath }))) {
559
+ differences.push('launcher');
560
+ }
434
561
  } catch {}
435
562
  }
436
563
  return differences;
@@ -608,7 +735,7 @@ export function createWindowsBootAutostartService({
608
735
  return { ...(await getState()), accepted: false };
609
736
  }
610
737
  }
611
- writeFileAtomic(launcherPath(), launcher);
738
+ writeFileAtomic(launcherPath(), generatedLauncher({ coverBytes: previous === null ? 0 : Buffer.byteLength(previous, 'utf8') }));
612
739
  const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
613
740
  const sid = parseSidFromWhoami(who.stdout);
614
741
  if (!sid) {
@@ -667,5 +794,6 @@ export function createWindowsBootAutostartService({
667
794
  getState,
668
795
  requestEnable,
669
796
  requestDisable,
797
+ refreshInstalledLauncher,
670
798
  };
671
799
  }
@@ -15,40 +15,45 @@ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
15
15
 
16
16
  /**
17
17
  * Resolves the instruction block for this relay, or '' when the preview lane
18
- * is off. Preview settings only change on relay restart, so the answer is
19
- * cached: this must not become a per-turn HTTP call.
18
+ * is off. Preview settings only change on relay restart, so the lane facts are
19
+ * cached: this must not become a per-turn HTTP call. The block itself is
20
+ * rendered per call for `{ conversationId }`, the conversation it is sent to,
21
+ * so the API calls it teaches are scoped to that conversation.
20
22
  */
21
23
  export function createPreviewInstructionsProvider({
22
24
  api,
23
25
  ttlMs = DEFAULT_TTL_MS,
24
26
  now = () => Date.now(),
25
27
  } = {}) {
26
- let cachedText = '';
28
+ // null: the lane is off; else its public base URL.
29
+ let cachedLane = null;
27
30
  let cachedUntil = 0;
28
31
  let inFlight = null;
29
32
 
30
33
  async function load() {
31
34
  const response = await api('GET', '/api/previews');
32
- const text = response?.enabled === true
33
- ? renderPreviewInstructionBlock({ publicBaseUrl: response?.publicBaseUrl || '' })
34
- : '';
35
- cachedText = text;
35
+ const lane = response?.enabled === true ? { publicBaseUrl: response?.publicBaseUrl || '' } : null;
36
+ cachedLane = lane;
36
37
  cachedUntil = now() + ttlMs;
37
- return text;
38
+ return lane;
38
39
  }
39
40
 
40
- return async function getPreviewInstructions() {
41
+ function render(lane, conversationId) {
42
+ return lane ? renderPreviewInstructionBlock({ publicBaseUrl: lane.publicBaseUrl, conversationId }) : '';
43
+ }
44
+
45
+ return async function getPreviewInstructions({ conversationId = '' } = {}) {
41
46
  if (typeof api !== 'function') return '';
42
- if (cachedUntil > now()) return cachedText;
47
+ if (cachedUntil > now()) return render(cachedLane, conversationId);
43
48
  if (!inFlight) {
44
49
  // A failed lookup is deliberately not cached, and yields no block: an
45
50
  // unreachable relay must neither lose the capability for the rest of the
46
51
  // process nor advertise one that would 503.
47
52
  inFlight = load()
48
- .catch(() => '')
53
+ .catch(() => null)
49
54
  .finally(() => { inFlight = null; });
50
55
  }
51
- return inFlight;
56
+ return render(await inFlight, conversationId);
52
57
  };
53
58
  }
54
59
 
@@ -15,9 +15,9 @@ export const PREVIEW_TOOL_NAME = 'preview';
15
15
 
16
16
  export const PREVIEW_TOOL_DESCRIPTION =
17
17
  'Publish a local web server or a static directory on a public preview URL so '
18
- + 'the user can open it from any device, or list/close existing previews. Use '
19
- + 'this whenever the user wants to see, try, or share something with a web UI '
20
- + '— never ask them to open localhost or forward a port. '
18
+ + 'the user can open it from any device, or list/close this conversation\'s '
19
+ + 'previews. Use this whenever the user wants to see, try, or share something '
20
+ + 'with a web UI — never ask them to open localhost or forward a port. '
21
21
  + 'For a dev server: call this FIRST with {action:"create", port} to get the '
22
22
  + 'basePath, then start the server configured to serve under that basePath '
23
23
  + '(Vite --base, Next basePath, Express mount prefix); the proxy forwards '
@@ -37,7 +37,7 @@ export const PREVIEW_TOOL_INPUT_SCHEMA = {
37
37
  action: {
38
38
  type: 'string',
39
39
  enum: ['create', 'list', 'close'],
40
- description: 'create: publish a port or directory. list: live previews. close: unpublish one.',
40
+ description: 'create: publish a port or directory. list: this conversation\'s live previews. close: unpublish one of them.',
41
41
  },
42
42
  port: {
43
43
  type: 'integer',
@@ -114,10 +114,19 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
114
114
  if (typeof api !== 'function') return { ok: false, error: 'Preview API unavailable in this worker' };
115
115
  const parsed = validatePreviewToolInput(input);
116
116
  if (!parsed.ok) return parsed;
117
+ // list and close reach only this conversation's previews: a preview's token
118
+ // is its only credential. The relay enforces the same scope for every
119
+ // request that names a conversation (routes/preview-routes.mjs). A create
120
+ // without one would land in no conversation's scope, so it is refused too.
121
+ const conversation = toText(conversationId);
122
+ const scope = `conversationId=${encodeURIComponent(conversation)}`;
123
+ if (!conversation) {
124
+ return { ok: false, error: 'This tool call is not bound to a conversation; it cannot publish, list or close previews' };
125
+ }
117
126
 
118
127
  try {
119
128
  if (parsed.action === 'list') {
120
- const response = await api('GET', '/api/previews');
129
+ const response = await api('GET', `/api/previews?${scope}`);
121
130
  return {
122
131
  ok: true,
123
132
  enabled: response?.enabled === true,
@@ -138,7 +147,7 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
138
147
  if (parsed.action === 'close') {
139
148
  let token = parsed.token;
140
149
  if (!token) {
141
- const response = await api('GET', `/api/previews?conversationId=${encodeURIComponent(conversationId)}`);
150
+ const response = await api('GET', `/api/previews?${scope}`);
142
151
  const live = response?.previews || [];
143
152
  if (live.length === 0) return { ok: false, error: 'No live previews in this conversation' };
144
153
  if (live.length > 1) {
@@ -150,12 +159,14 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
150
159
  }
151
160
  token = live[0].token;
152
161
  }
153
- const response = await api('DELETE', `/api/previews/${token}`);
162
+ // Another conversation's token is refused by the relay (403); its
163
+ // message comes back as the answer.
164
+ const response = await api('DELETE', `/api/previews/${token}?${scope}`);
154
165
  return { ok: true, closed: { token, label: response?.preview?.label || null } };
155
166
  }
156
167
 
157
168
  const body = {
158
- conversationId,
169
+ conversationId: conversation,
159
170
  label: parsed.label || undefined,
160
171
  ...(parsed.port ? { port: parsed.port } : { dir: parsed.dir }),
161
172
  };
@@ -176,26 +187,33 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
176
187
  : hint,
177
188
  };
178
189
  } catch (error) {
179
- return { ok: false, error: String(error?.message || error) };
190
+ // A scope refusal (403) in its own words, without the HTTP wrapper.
191
+ const refusal = Number(error?.status) === 403 ? toText(error?.detail) : '';
192
+ return { ok: false, error: refusal || String(error?.message || error) };
180
193
  }
181
194
  }
182
195
 
183
196
  /**
184
197
  * The instruction block for providers without a custom-tool surface. Generated
185
198
  * from the same description/schema strings the tool uses; keep any wording
186
- * change up in the constants, never here.
199
+ * change up in the constants, never here. `conversationId` is the conversation
200
+ * the block is sent to: written into every call, so the model's natural call
201
+ * is the scoped one (without it, a plain GET would list the whole relay).
187
202
  */
188
- export function renderPreviewInstructionBlock({ publicBaseUrl = '' } = {}) {
203
+ export function renderPreviewInstructionBlock({ publicBaseUrl = '', conversationId = '' } = {}) {
189
204
  const base = toText(publicBaseUrl) || 'https://<preview-host>';
205
+ const id = toText(conversationId);
206
+ const conv = id ? JSON.stringify(id).slice(1, -1) : '<conv>';
207
+ const convQuery = id ? encodeURIComponent(id) : '<conv>';
190
208
  return `## Preview servers
191
209
 
192
210
  ${PREVIEW_TOOL_DESCRIPTION}
193
211
 
194
212
  There is no \`${PREVIEW_TOOL_NAME}\` tool on this provider — use the authenticated localhost API (same auth as /api/relay/shutdown):
195
213
 
196
- - Publish a running dev server: \`POST /api/previews\` with \`{ "conversationId": "<conv>", "port": 5173, "label": "web app" }\` → \`{ "url": "${base}/test_<token>/", "basePath": "/test_<token>/" }\`. Register FIRST, then start the server under the returned basePath.
197
- - Publish files without a server: \`POST /api/previews\` with \`{ "conversationId": "<conv>", "dir": "./dist", "label": "built site" }\` — the relay serves the directory itself. \`dir\` must live inside the conversation's workspace root.
198
- - List: \`GET /api/previews\` (\`?conversationId=\` filters). Close: \`DELETE /api/previews/:token\` — closes the link only, never the dev server behind it.
214
+ - Publish a running dev server: \`POST /api/previews\` with \`{ "conversationId": "${conv}", "port": 5173, "label": "web app" }\` → \`{ "url": "${base}/test_<token>/", "basePath": "/test_<token>/" }\`. Register FIRST, then start the server under the returned basePath.
215
+ - Publish files without a server: \`POST /api/previews\` with \`{ "conversationId": "${conv}", "dir": "./dist", "label": "built site" }\` — the relay serves the directory itself. \`dir\` must live inside the conversation's workspace root.
216
+ - List this conversation's previews: \`GET /api/previews?conversationId=${convQuery}\`. Close one: \`DELETE /api/previews/:token?conversationId=${convQuery}\` — closes the link only, never the dev server behind it. Always pass the conversation: you may list and close only this conversation's previews, another conversation's is refused (403), and the relay-wide list is the user's (Settings → Previews).
199
217
 
200
218
  A 503 with \`details\` means the preview lane is disabled or misconfigured — surface those details to the user. A 400 names exactly what was refused (relay-owned port, non-loopback host, dir outside the workspace root).`;
201
219
  }
@@ -0,0 +1,63 @@
1
+ // What an answer to a question card means, in one place. The relay's answer
2
+ // route (a card that takes no typed text accepts only its choices), the card
3
+ // view (no reply box on such a card; server/public/app/question-answer-rule.mjs
4
+ // is its browser copy) and every reader of an approve / deny
5
+ // card (OAR's own remote-relay approvals, Claude Cloud's and Copilot's
6
+ // permission cards) apply the same rule: an answer is a choice when it equals
7
+ // the choice's label trimmed and in any case.
8
+
9
+ function answerKey(value) {
10
+ return String(value ?? '').trim().toLowerCase();
11
+ }
12
+
13
+ /** The card's own label for `answer`, or null when it is none of `choices`. */
14
+ export function matchQuestionChoice(answer, choices) {
15
+ const key = answerKey(answer);
16
+ if (!key || !Array.isArray(choices)) return null;
17
+ for (const choice of choices) {
18
+ const label = String(choice ?? '').trim();
19
+ if (label && answerKey(label) === key) return label;
20
+ }
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * An approve / deny card's answer: 'approve' for its approve choice, 'deny'
26
+ * for its deny choice, null for anything else. A caller decides what null
27
+ * means for its card; it is never the user's plain Deny.
28
+ */
29
+ export function readApprovalAnswer(answer, { approve, deny }) {
30
+ const key = answerKey(answer);
31
+ if (!key) return null;
32
+ if (key === answerKey(approve)) return 'approve';
33
+ if (key === answerKey(deny)) return 'deny';
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Whether a card takes typed text. A card with choices takes it only when it
39
+ * says so (`allowFreeform: true`); a missing flag counts as false there, as
40
+ * the create route and the relay's question rows read it. Without choices a
41
+ * card always does: it could not be answered otherwise.
42
+ */
43
+ export function questionAcceptsFreeText(question) {
44
+ const choices = Array.isArray(question?.choices) ? question.choices : [];
45
+ return question?.allowFreeform === true || choices.length === 0;
46
+ }
47
+
48
+ /**
49
+ * The answer a card that takes no typed text accepts, as the card's labels:
50
+ * one choice, or on a multi-select card several, ", "-joined the way the card
51
+ * sends them. null when the answer is not made of the card's choices.
52
+ */
53
+ export function closedQuestionAnswer({ choices, multiSelect = false } = {}, answer) {
54
+ const single = matchQuestionChoice(answer, choices);
55
+ if (single || !multiSelect) return single;
56
+ const labels = [];
57
+ for (const part of String(answer ?? '').split(',')) {
58
+ const label = matchQuestionChoice(part, choices);
59
+ if (!label) return null;
60
+ if (!labels.includes(label)) labels.push(label);
61
+ }
62
+ return labels.length ? labels.join(', ') : null;
63
+ }
@@ -37,7 +37,12 @@ export const RELAY_EVENT_HEARTBEAT_TYPE = 'heartbeat';
37
37
  export const RELAY_EVENT_LINK_LOST_TYPE = 'link_lost';
38
38
  export const RELAY_EVENT_LINK_RESTORED_TYPE = 'link_restored';
39
39
  const LINK_EVENT_TYPES = Object.freeze([RELAY_EVENT_LINK_LOST_TYPE, RELAY_EVENT_LINK_RESTORED_TYPE]);
40
- const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBEAT_TYPE, ...LINK_EVENT_TYPES]);
40
+ // An event of this relay, not of a session: its runtime started again. Only
41
+ // a subscription that names it gets it (never the default), and one that
42
+ // names nothing else needs no sessions.
43
+ export const RELAY_EVENT_RELAY_RESTARTED_TYPE = 'relay_restarted';
44
+ export const RELAY_EVENT_RELAY_LEVEL_TYPES = Object.freeze([RELAY_EVENT_RELAY_RESTARTED_TYPE]);
45
+ const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBEAT_TYPE, ...LINK_EVENT_TYPES, ...RELAY_EVENT_RELAY_LEVEL_TYPES]);
41
46
 
42
47
  // Not an event of its own but a narrower task_finished a subscription can
43
48
  // name: the end of a task other than a shell command (a sub-agent's). Its
@@ -46,19 +51,28 @@ const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBE
46
51
  // whole context; a failed one is still a task_failed.
47
52
  export const RELAY_EVENT_SUBAGENT_FINISHED_TYPE = 'subagent_finished';
48
53
 
49
- // What `events` may name: the event types and the narrower subagent_finished.
54
+ // What `events` may name: the event types, the narrower subagent_finished
55
+ // and the relay's own relay_restarted.
50
56
  export const RELAY_EVENT_SUBSCRIBABLE_TYPES = Object.freeze([
51
57
  ...RELAY_EVENT_TYPES.slice(0, RELAY_EVENT_TYPES.indexOf('task_finished') + 1),
52
58
  RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
53
59
  ...RELAY_EVENT_TYPES.slice(RELAY_EVENT_TYPES.indexOf('task_finished') + 1),
60
+ ...RELAY_EVENT_RELAY_LEVEL_TYPES,
54
61
  ]);
55
62
 
56
63
  // What a subscription gets when it names no events: everything but the
57
64
  // start of a task, and of the task ends that went well only a sub-agent's
58
65
  // (subagent_finished, not task_finished): the start of a task and every
59
- // shell command's end a watcher asks for explicitly.
66
+ // shell command's end a watcher asks for explicitly. relay_restarted is
67
+ // never a default: a wake-up after each restart is asked for by name.
60
68
  export const RELAY_EVENT_DEFAULT_TYPES = Object.freeze(RELAY_EVENT_SUBSCRIBABLE_TYPES
61
- .filter((type) => type !== 'task_started' && type !== 'task_finished'));
69
+ .filter((type) => type !== 'task_started' && type !== 'task_finished' && !RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type)));
70
+
71
+ /** Whether `events` names relay-level events only (then no sessions are needed). */
72
+ export function isRelayLevelOnly(types) {
73
+ const list = (Array.isArray(types) ? types : []).map(toText).filter(Boolean);
74
+ return list.length > 0 && list.every((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
75
+ }
62
76
 
63
77
  // The default before subagent_finished: every type but task_started. A
64
78
  // subscription made without events stored exactly this list.
@@ -249,11 +263,47 @@ function taskOutcome(detail) {
249
263
  return parts.join(', ');
250
264
  }
251
265
 
266
+ /** `2026-10-20 14:05 UTC`, or '' for a time that does not parse. */
267
+ function utcMinute(value) {
268
+ const ms = Date.parse(String(value || ''));
269
+ return Number.isFinite(ms) ? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
270
+ }
271
+
272
+ // The end of a relay_restarted line: what the agent should do, and the loop
273
+ // guard said up front (services/relay-event-service.mjs noteRelayStarted).
274
+ export const RELAY_RESTARTED_ADVICE = 'Timers and jobs your CLI scheduled before may be gone: check what you were waiting for and carry on. '
275
+ + 'A restart requested while this turn runs does not wake you again.';
276
+
277
+ function relayRestartedLine(prefix, detail) {
278
+ const startedAt = utcMinute(detail.startedAt);
279
+ const stoppedAt = utcMinute(detail.stoppedAt);
280
+ const requestedAt = utcMinute(detail.requestedAt);
281
+ const since = `${prefix}${RELAY_EVENT_RELAY_RESTARTED_TYPE}: the relay runs again${startedAt ? ` since ${startedAt}` : ''}`;
282
+ if (detail.stop !== 'requested' && detail.stop !== 'stopped') {
283
+ // No record: a crash, a forced stop (on Windows a supervisor's stop ends
284
+ // the process without its signal handler) or a reboot.
285
+ return `${since}; the relay did not record how it stopped (for example a crash, a forced stop or a reboot). ${RELAY_RESTARTED_ADVICE}`;
286
+ }
287
+ let how;
288
+ if (detail.stop === 'requested') {
289
+ const action = oneLine(detail.action, 20) === 'shutdown' ? 'shutdown' : 'restart';
290
+ const by = oneLine(detail.requestedBy, 80);
291
+ const reason = oneLine(detail.reason, 140);
292
+ how = `after a ${action} requested${by ? ` by ${quote(by, 80)}` : ''}${requestedAt ? ` at ${requestedAt}` : ''}`
293
+ + `${reason ? ` (reason ${quote(reason, 140)})` : ''}`;
294
+ } else {
295
+ const reason = oneLine(detail.reason, 140);
296
+ how = `after it was stopped${reason ? ` (${reason.replace(/[()]/g, '')})` : ''}${stoppedAt ? ` at ${stoppedAt}` : ''}`;
297
+ }
298
+ return `${since}, ${how}. ${RELAY_RESTARTED_ADVICE}`;
299
+ }
300
+
252
301
  /** One line of the event message, the watcher's to read. */
253
302
  export function formatRelayEventLine(event) {
254
303
  const type = oneLine(event?.type, 40);
255
304
  const detail = event?.detail && typeof event.detail === 'object' ? event.detail : {};
256
305
  const prefix = linePrefix(event);
306
+ if (type === RELAY_EVENT_RELAY_RESTARTED_TYPE) return relayRestartedLine(prefix, detail);
257
307
  if (type === RELAY_EVENT_LINK_LOST_TYPE) {
258
308
  const since = oneLine(detail.since, 40);
259
309
  return `${prefix}${type}: no answer from relay ${quote(relayDisplayName(event), 60)}${since ? ` since ${since}` : ''}; your subscriptions there saw nothing meanwhile`;