@oar-sh/oar 0.9.10 → 0.9.12

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 (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -3,6 +3,11 @@ 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 {
7
+ describeExecError,
8
+ describeSpawnEnvironment,
9
+ windowsSystemToolPath,
10
+ } from '../../shared/windows-system-tools.mjs';
6
11
  import {
7
12
  LEGACY_WINDOWS_AUTOSTART_FILENAME,
8
13
  WINDOWS_AUTOSTART_FILENAME,
@@ -40,7 +45,13 @@ export const LEGACY_BOOT_TASK_NAME = 'copilot-remote-relay';
40
45
  export const BOOT_LAUNCHER_FILENAME = 'oar-web-relay-launcher.cmd';
41
46
  export const BOOT_TASK_XML_FILENAME = 'oar-relay-task.xml';
42
47
  export const BOOT_ELEVATION_SCRIPT_FILENAME = 'oar-relay-elevate.ps1';
48
+ // Where a regenerated launcher keeps the one it replaced, when they differ.
49
+ export const BOOT_LAUNCHER_BACKUP_SUFFIX = '.bak';
43
50
  const ELEVATION_TIMEOUT_MS = 120_000;
51
+ export const BOOT_TEMP_WAIT_SECONDS = 120;
52
+ export const BOOT_LAUNCHER_LOG_FILENAME = 'oar-web-relay-launcher.log';
53
+ // Each schtasks query; the settings page waits on two of them.
54
+ const QUERY_TIMEOUT_MS = 6_000;
44
55
 
45
56
  function normalizeText(value) {
46
57
  return String(value == null ? '' : value).trim();
@@ -58,6 +69,19 @@ function escapeXml(value) {
58
69
  /**
59
70
  * The launcher the task executes. Same shape as the sign-in .cmd minus the
60
71
  * `title` line: a boot task runs in session 0 where no console is ever shown.
72
+ *
73
+ * Node's folder goes at the end of PATH. The relay itself runs on the
74
+ * absolute path below, but a boot task's PATH can lack the user's Node (a
75
+ * version manager's folder, an entry that refers to an unexpanded variable),
76
+ * and the CLIs and commands the workers start may call a bare `node`. At the
77
+ * end it only matters when PATH has no Node of its own.
78
+ *
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.
61
85
  */
62
86
  export function buildBootLauncherScript({
63
87
  packageRoot,
@@ -78,10 +102,12 @@ export function buildBootLauncherScript({
78
102
  }
79
103
  const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
80
104
  const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
105
+ const resolvedNodePath = pathImpl.resolve(nodePath);
81
106
  const lines = [
82
107
  '@echo off',
83
108
  'rem Generated by OAR settings (system-startup autostart). Regenerated on',
84
109
  'rem every settings change - do not edit by hand.',
110
+ `set "PATH=%PATH%;${escapeBatchValue(pathImpl.dirname(resolvedNodePath))}"`,
85
111
  ];
86
112
  if (normalizeText(configPath)) {
87
113
  lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
@@ -90,13 +116,36 @@ export function buildBootLauncherScript({
90
116
  ? pathImpl.resolve(String(workingDirectory))
91
117
  : resolvedPackageRoot;
92
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="',
93
133
  `cd /d "${escapeBatchValue(cwdTarget)}"`,
94
- `"${escapeBatchValue(pathImpl.resolve(nodePath))}" "${escapeBatchValue(serverPath)}"`,
134
+ `"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
95
135
  '',
96
136
  );
97
137
  return lines.join('\r\n');
98
138
  }
99
139
 
140
+ function unescapeXml(value) {
141
+ return String(value)
142
+ .replaceAll('"', '"')
143
+ .replaceAll(''', "'")
144
+ .replaceAll('&lt;', '<')
145
+ .replaceAll('&gt;', '>')
146
+ .replaceAll('&amp;', '&');
147
+ }
148
+
100
149
  /**
101
150
  * Task Scheduler XML, settings ported verbatim from the proven manual task.
102
151
  * The S4U principal is registered by SID, not account name: a by-name
@@ -176,17 +225,64 @@ export function parseTaskXml(xmlText) {
176
225
  const text = String(xmlText == null ? '' : xmlText);
177
226
  const grab = (tag) => {
178
227
  const match = text.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
179
- return match ? match[1].trim() : null;
228
+ return match ? unescapeXml(match[1]).trim() : null;
180
229
  };
230
+ const bootTrigger = text.match(/<BootTrigger>([\s\S]*?)<\/BootTrigger>/);
231
+ const bootDelay = bootTrigger ? (bootTrigger[1].match(/<Delay>([^<]*)<\/Delay>/)?.[1]?.trim() || null) : null;
181
232
  return {
182
233
  command: grab('Command'),
183
234
  argumentsLine: grab('Arguments'),
184
235
  workingDirectory: grab('WorkingDirectory'),
185
236
  logonType: grab('LogonType'),
186
237
  userId: grab('UserId'),
238
+ hasBootTrigger: Boolean(bootTrigger),
239
+ bootDelay,
187
240
  };
188
241
  }
189
242
 
243
+ function sameText(a, b) {
244
+ return normalizeText(a).toLowerCase() === normalizeText(b).toLowerCase();
245
+ }
246
+
247
+ // Windows paths: no case, either slash, no trailing separator.
248
+ function samePath(a, b) {
249
+ const normalize = (value) => normalizeText(value).replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase();
250
+ return normalize(a) === normalize(b);
251
+ }
252
+
253
+ /**
254
+ * How a task that runs our launcher differs from the one buildBootTaskXml
255
+ * registers, as short names (empty when it does not). Only what the generated
256
+ * XML sets is compared: Task Scheduler adds its own defaults on export, and
257
+ * the run level, for one, is not set by OAR.
258
+ */
259
+ export function describeTaskCustomisations(xmlText, { launcherPath, workingDirectory } = {}) {
260
+ const parsed = parseTaskXml(xmlText);
261
+ const differences = [];
262
+ if (!sameText(parsed.command, 'C:\\Windows\\System32\\cmd.exe')
263
+ || normalizeText(parsed.argumentsLine).replace(/\s+/g, ' ').toLowerCase()
264
+ !== `/d /c "${normalizeText(launcherPath)}"`.toLowerCase()) {
265
+ differences.push('action');
266
+ }
267
+ if (normalizeText(workingDirectory) && !samePath(parsed.workingDirectory, workingDirectory)) {
268
+ differences.push('working-directory');
269
+ }
270
+ if (!sameText(parsed.logonType, 'S4U')) differences.push('logon-type');
271
+ if (!parsed.hasBootTrigger) differences.push('trigger');
272
+ else if (!sameText(parsed.bootDelay, 'PT30S')) differences.push('boot-delay');
273
+ return differences;
274
+ }
275
+
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
+ }
285
+
190
286
  /**
191
287
  * Ours or someone else's? A task counts as ours when its action runs our
192
288
  * launcher path; anything else under our name is `foreign` and the UI offers
@@ -206,23 +302,25 @@ export function classifyTaskXml(xmlText, { launcherPath } = {}) {
206
302
  * and nothing else — in one script, so one UAC covers the whole transition
207
303
  * out of any mixed state (legacy-named task, hand-made task, fresh install).
208
304
  */
209
- export function buildElevationScript({ action, xmlPath } = {}) {
305
+ export function buildElevationScript({ action, xmlPath, schtasksPath = 'schtasks' } = {}) {
306
+ // PowerShell runs a quoted path only behind the call operator.
307
+ const schtasks = /[\\/]/.test(schtasksPath) ? `& "${schtasksPath}"` : schtasksPath;
210
308
  if (action === 'enable') {
211
309
  if (!normalizeText(xmlPath)) {
212
310
  throw new Error('The enable sweep needs the task XML path.');
213
311
  }
214
312
  return [
215
- `schtasks /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
216
- `schtasks /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
217
- `schtasks /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
313
+ `${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
314
+ `${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
315
+ `${schtasks} /create /tn "${BOOT_TASK_NAME}" /xml "${xmlPath}"`,
218
316
  'exit $LASTEXITCODE',
219
317
  '',
220
318
  ].join('\r\n');
221
319
  }
222
320
  if (action === 'disable') {
223
321
  return [
224
- `schtasks /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
225
- `schtasks /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
322
+ `${schtasks} /delete /tn "${BOOT_TASK_NAME}" /f 2>$null`,
323
+ `${schtasks} /delete /tn "${LEGACY_BOOT_TASK_NAME}" /f 2>$null`,
226
324
  'exit 0',
227
325
  '',
228
326
  ].join('\r\n');
@@ -236,13 +334,19 @@ export function parseSidFromWhoami(stdout) {
236
334
  return match ? match[1] : null;
237
335
  }
238
336
 
337
+ // `startError` is set when the program did not run at all (not found, timed
338
+ // out): unlike an exit code, that says nothing about the task.
239
339
  function defaultExecFileImpl(file, args) {
240
340
  return new Promise((resolve) => {
241
- execFile(file, args, { windowsHide: true }, (error, stdout, stderr) => {
341
+ execFile(file, args, { windowsHide: true, timeout: QUERY_TIMEOUT_MS }, (error, stdout, stderr) => {
342
+ const exitCode = typeof error?.code === 'number' ? error.code : null;
242
343
  resolve({
243
- code: error ? (typeof error.code === 'number' ? error.code : 1) : 0,
344
+ code: error ? (exitCode ?? 1) : 0,
244
345
  stdout: String(stdout || ''),
245
346
  stderr: String(stderr || ''),
347
+ startError: error && (exitCode === null || error.killed)
348
+ ? describeExecError(error)
349
+ : null,
246
350
  });
247
351
  });
248
352
  });
@@ -262,12 +366,16 @@ export function createWindowsBootAutostartService({
262
366
  clearTimeoutImpl = clearTimeout,
263
367
  now = () => Date.now(),
264
368
  logger = console,
369
+ // Full path of a Windows program (schtasks, whoami, powershell).
370
+ systemToolPath = (name) => windowsSystemToolPath(name, { env }),
265
371
  } = {}) {
266
372
  const supported = platform === 'win32';
267
373
  // One elevation in flight at a time; getState() reports it and the
268
374
  // completion callback resolves it.
269
375
  let pending = null; // { action, startedAt, timer, child }
270
376
  let lastError = null;
377
+ // The launcher an enable replaced, kept beside it (see requestEnable).
378
+ let launcherBackupPath = null;
271
379
 
272
380
  const oarRoot = () => resolveOarRoot(env, platform);
273
381
  const launcherPath = () => pathImpl.join(oarRoot(), BOOT_LAUNCHER_FILENAME);
@@ -278,9 +386,54 @@ export function createWindowsBootAutostartService({
278
386
  try { logger?.log?.(`[boot-autostart] ${message}`); } catch {}
279
387
  }
280
388
 
389
+ const tool = (name) => systemToolPath(name);
390
+
391
+ /**
392
+ * The task's XML, null when there is no such task, or `{ readError }` when
393
+ * schtasks itself could not run: that says nothing about the task, so it
394
+ * must not read as "missing".
395
+ */
281
396
  async function queryTaskXml(taskName) {
282
- const result = await execFileImpl('schtasks', ['/query', '/tn', taskName, '/xml']);
283
- return result.code === 0 ? result.stdout : null;
397
+ let result;
398
+ try {
399
+ result = await execFileImpl(tool('schtasks'), ['/query', '/tn', taskName, '/xml']);
400
+ } catch (error) {
401
+ result = { code: 1, stdout: '', stderr: '', startError: describeExecError(error) };
402
+ }
403
+ if (result?.startError) {
404
+ log(`schtasks /query ${taskName} did not run: ${result.startError}; ${describeSpawnEnvironment(env, platform)}`);
405
+ // The details stay in the log (they can name folders on the host).
406
+ return { readError: 'schtasks could not be run; the relay log has the details.' };
407
+ }
408
+ return result?.code === 0 ? result.stdout : null;
409
+ }
410
+
411
+ function readLauncher() {
412
+ try {
413
+ return fsImpl.existsSync(launcherPath()) ? String(fsImpl.readFileSync(launcherPath(), 'utf8')) : null;
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ function generatedLauncher() {
420
+ return buildBootLauncherScript({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl });
421
+ }
422
+
423
+ /** What differs from the generated task and launcher, for a task that runs our launcher. */
424
+ function customisationsOf(xmlText) {
425
+ const differences = describeTaskCustomisations(xmlText, {
426
+ launcherPath: launcherPath(),
427
+ workingDirectory: oarRoot(),
428
+ });
429
+ const launcher = readLauncher();
430
+ if (launcher === null) differences.push('launcher-missing');
431
+ else {
432
+ try {
433
+ if (!launcherScriptsMatch(launcher, generatedLauncher())) differences.push('launcher');
434
+ } catch {}
435
+ }
436
+ return differences;
284
437
  }
285
438
 
286
439
  function startupCmdPaths() {
@@ -338,16 +491,29 @@ export function createWindowsBootAutostartService({
338
491
  }
339
492
  const ownXml = await queryTaskXml(BOOT_TASK_NAME);
340
493
  const legacyXml = await queryTaskXml(LEGACY_BOOT_TASK_NAME);
341
- const taskStatus = ownXml === null
342
- ? (legacyXml !== null ? 'legacy' : 'missing')
343
- : classifyTaskXml(ownXml, { launcherPath: launcherPath() });
494
+ const readError = ownXml?.readError || null;
495
+ const legacyKnown = typeof legacyXml === 'string';
496
+ let taskStatus;
497
+ if (readError) taskStatus = 'unknown';
498
+ else if (ownXml === null) taskStatus = legacyKnown ? 'legacy' : 'missing';
499
+ else taskStatus = classifyTaskXml(ownXml, { launcherPath: launcherPath() });
500
+ // A task that runs our launcher but is not quite the one OAR registers (no
501
+ // boot delay, an edited launcher) still is boot mode; it is reported,
502
+ // never failed on.
503
+ const customisations = taskStatus === 'ready' ? customisationsOf(ownXml) : [];
504
+ let launcherPresent = false;
505
+ try { launcherPresent = fsImpl.existsSync(launcherPath()); } catch {}
344
506
  return {
345
507
  supported: true,
346
508
  platform,
347
509
  taskStatus,
348
510
  taskName: BOOT_TASK_NAME,
349
- legacyTaskPresent: legacyXml !== null,
350
- launcherPath: fsImpl.existsSync(launcherPath()) ? launcherPath() : null,
511
+ legacyTaskPresent: legacyKnown,
512
+ launcherPath: launcherPresent ? launcherPath() : null,
513
+ customised: customisations.length > 0,
514
+ customisations,
515
+ launcherBackupPath,
516
+ readError,
351
517
  pendingElevation: pending ? pending.action : null,
352
518
  lastError,
353
519
  manualCommand: manualCommandFor(pending?.action || 'enable'),
@@ -374,12 +540,13 @@ export function createWindowsBootAutostartService({
374
540
  * script, so its exit is the "elevation finished (or was declined)" signal.
375
541
  */
376
542
  function spawnElevated(action, onSettled) {
377
- const child = spawnImpl('powershell.exe', [
543
+ const powershell = tool('powershell.exe');
544
+ const child = spawnImpl(powershell, [
378
545
  '-NoProfile',
379
546
  '-NonInteractive',
380
547
  '-Command',
381
548
  [
382
- '$p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList',
549
+ `$p = Start-Process -FilePath '${powershell.replaceAll("'", "''")}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList`,
383
550
  `'-NoProfile','-ExecutionPolicy','Bypass','-File','${elevationScriptPath()}'`,
384
551
  '; exit $p.ExitCode',
385
552
  ].join(' '),
@@ -425,8 +592,24 @@ export function createWindowsBootAutostartService({
425
592
  return { ...(await getState()), accepted: false };
426
593
  }
427
594
  lastError = null;
428
- writeFileAtomic(launcherPath(), buildBootLauncherScript({ packageRoot, nodePath, configPath, workingDirectory: oarRoot(), pathImpl }));
429
- const who = await execFileImpl('whoami', ['/user', '/fo', 'csv']);
595
+ const launcher = generatedLauncher();
596
+ const previous = readLauncher();
597
+ if (previous !== null && !launcherScriptsMatch(previous, launcher)) {
598
+ // An edited (or older) launcher is replaced, but kept: one copy, the
599
+ // latest replaced one.
600
+ const backupPath = `${launcherPath()}${BOOT_LAUNCHER_BACKUP_SUFFIX}`;
601
+ try {
602
+ fsImpl.writeFileSync(backupPath, previous);
603
+ launcherBackupPath = backupPath;
604
+ log(`the launcher differed from the generated one; saved it as ${backupPath}`);
605
+ } catch (error) {
606
+ log(`could not save the current launcher as ${backupPath}: ${error?.message || error}`);
607
+ lastError = 'Could not keep a copy of the current launcher next to it, so it was not replaced; the relay log has the details.';
608
+ return { ...(await getState()), accepted: false };
609
+ }
610
+ }
611
+ writeFileAtomic(launcherPath(), launcher);
612
+ const who = await execFileImpl(tool('whoami'), ['/user', '/fo', 'csv']);
430
613
  const sid = parseSidFromWhoami(who.stdout);
431
614
  if (!sid) {
432
615
  lastError = 'Could not determine the current user SID (whoami /user failed).';
@@ -438,7 +621,7 @@ export function createWindowsBootAutostartService({
438
621
  packageRoot: pathImpl.resolve(String(packageRoot)),
439
622
  workingDirectory: oarRoot(),
440
623
  })));
441
- writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath() }));
624
+ writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'enable', xmlPath: xmlPath(), schtasksPath: tool('schtasks') }));
442
625
  spawnElevated('enable', (ok) => {
443
626
  if (ok) {
444
627
  // Mutual exclusion: boot mode owns startup now, so the sign-in
@@ -463,7 +646,8 @@ export function createWindowsBootAutostartService({
463
646
  return { ...(await getState()), accepted: false };
464
647
  }
465
648
  lastError = null;
466
- writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable' }));
649
+ launcherBackupPath = null;
650
+ writeFileAtomic(elevationScriptPath(), buildElevationScript({ action: 'disable', schtasksPath: tool('schtasks') }));
467
651
  spawnElevated('disable', (ok) => {
468
652
  if (ok) {
469
653
  try {
@@ -0,0 +1,83 @@
1
+ // How long a backgrounded Bash command of a Claude session may run before
2
+ // Claude Code stops it, when the agent passes no `timeout` of its own. The
3
+ // CLI's own default is 30 minutes; long test or GPU runs were cut off there.
4
+ //
5
+ // Shared so the settings slider, the relay that stores the value and the
6
+ // worker that hands it to the CLI agree on bounds, steps and formatting. The
7
+ // browser mirrors the default in
8
+ // server/public/app/claude-background-command-limit-options.mjs (only
9
+ // server/public is served), and the slider's static markup in
10
+ // server/public/index.html starts at it; unit tests pin both to this module.
11
+
12
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES = 30;
13
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES = 480;
14
+ export const CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES = 30;
15
+ export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES = 120;
16
+
17
+ /** Clamped to 30 min … 8 h and snapped to the slider's 30-minute steps. */
18
+ export function normalizeClaudeBackgroundCommandLimitMinutes(value, fallback = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES) {
19
+ if (value === null || value === undefined) return fallback;
20
+ if (typeof value === 'string' && value.trim() === '') return fallback;
21
+ if (typeof value !== 'number' && typeof value !== 'string') return fallback;
22
+ const numeric = Number(value);
23
+ if (!Number.isFinite(numeric)) return fallback;
24
+ const step = CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES;
25
+ const snapped = Math.round(numeric / step) * step;
26
+ return Math.min(
27
+ CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
28
+ Math.max(CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES, snapped),
29
+ );
30
+ }
31
+
32
+ export function claudeBackgroundCommandLimitMinutesToMs(minutes) {
33
+ return normalizeClaudeBackgroundCommandLimitMinutes(minutes) * 60_000;
34
+ }
35
+
36
+ export const DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS = DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES * 60_000;
37
+
38
+ /** An unset setting means "never configured", which is the default. */
39
+ export function readClaudeBackgroundCommandLimitSetting(storedValue) {
40
+ const text = storedValue === null || storedValue === undefined ? '' : String(storedValue).trim();
41
+ return normalizeClaudeBackgroundCommandLimitMinutes(text);
42
+ }
43
+
44
+ export function parseClaudeBackgroundCommandLimitUpdate(value) {
45
+ const minutes = normalizeClaudeBackgroundCommandLimitMinutes(value, null);
46
+ if (minutes === null) return { ok: false, error: 'backgroundCommandLimitMinutes must be a number' };
47
+ return { ok: true, minutes };
48
+ }
49
+
50
+ export function formatClaudeBackgroundCommandLimitLabel(minutes) {
51
+ const normalized = normalizeClaudeBackgroundCommandLimitMinutes(minutes);
52
+ if (normalized < 60) return `${normalized} min`;
53
+ const hours = Math.floor(normalized / 60);
54
+ const remainder = normalized % 60;
55
+ return remainder ? `${hours} h ${remainder} min` : `${hours} h`;
56
+ }
57
+
58
+ /** The fields every Claude settings payload carries for the slider. */
59
+ export function claudeBackgroundCommandLimitSettingsFields(settings = {}) {
60
+ return {
61
+ backgroundCommandLimitMinutes: normalizeClaudeBackgroundCommandLimitMinutes(settings?.backgroundCommandLimitMinutes),
62
+ backgroundCommandLimitMinMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MIN_MINUTES,
63
+ backgroundCommandLimitMaxMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_MAX_MINUTES,
64
+ backgroundCommandLimitStepMinutes: CLAUDE_BACKGROUND_COMMAND_LIMIT_STEP_MINUTES,
65
+ backgroundCommandLimitDefaultMinutes: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * What a delivery's `settings` does to the worker's limit (ms): an absent or
71
+ * junk key (an older relay) keeps the last known value; a present one is
72
+ * clamped like the setting itself.
73
+ */
74
+ export function resolveDeliveredClaudeBackgroundCommandLimitMs(current, settings) {
75
+ const kept = Number.isFinite(Number(current)) && Number(current) > 0
76
+ ? claudeBackgroundCommandLimitMinutesToMs(Number(current) / 60_000)
77
+ : DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MS;
78
+ if (!settings || typeof settings !== 'object') return kept;
79
+ if (!Object.prototype.hasOwnProperty.call(settings, 'claudeBackgroundCommandLimitMs')) return kept;
80
+ const delivered = Number(settings.claudeBackgroundCommandLimitMs);
81
+ if (settings.claudeBackgroundCommandLimitMs === null || !Number.isFinite(delivered) || delivered <= 0) return kept;
82
+ return claudeBackgroundCommandLimitMinutesToMs(delivered / 60_000);
83
+ }
@@ -25,8 +25,10 @@ export const PREVIEW_TOOL_DESCRIPTION =
25
25
  + 'paths will not work. For plain files or a build output: pass {action:'
26
26
  + '"create", dir} instead and the relay serves the directory itself — no dev '
27
27
  + 'server needed. Always tell the user the returned URL and that the link is '
28
- + 'public: anyone who has it can reach the app without logging in. Previews '
29
- + 'never expire on their own; close them with {action:"close"} when the user '
28
+ + 'public: anyone who has it can reach the app without logging in. Publishing '
29
+ + 'the same port or directory again in this conversation returns the same '
30
+ + 'link. Previews never expire and survive a relay restart (a port preview '
31
+ + 'until the machine reboots); close them with {action:"close"} when the user '
30
32
  + 'is done.';
31
33
 
32
34
  export const PREVIEW_TOOL_INPUT_SCHEMA = {
@@ -126,6 +128,9 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
126
128
  target: entry.mode === 'static' ? entry.rootDir : `localhost:${entry.targetPort}`,
127
129
  online: entry.online,
128
130
  conversationId: entry.conversationId,
131
+ // Stored but not served: the relay could not check it after a
132
+ // restart and tries again. It can be closed.
133
+ ...(entry.unverified === true ? { unverified: true } : {}),
129
134
  })),
130
135
  };
131
136
  }
@@ -155,15 +160,20 @@ export async function executePreviewTool(input, { api, conversationId = '' } = {
155
160
  ...(parsed.port ? { port: parsed.port } : { dir: parsed.dir }),
156
161
  };
157
162
  const response = await api('POST', '/api/previews', body);
163
+ const existing = response?.existing === true;
164
+ const hint = parsed.port
165
+ ? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Tell the user the URL and that the link is public.`
166
+ : 'The directory is served as-is; relative asset paths work, root-absolute ones do not. Tell the user the URL and that the link is public.';
158
167
  return {
159
168
  ok: true,
169
+ existing,
160
170
  url: response?.url,
161
171
  basePath: response?.basePath,
162
172
  token: response?.preview?.token,
163
173
  mode: response?.preview?.mode || (parsed.port ? 'port' : 'static'),
164
- hint: parsed.port
165
- ? `Serve the app under ${response?.basePath} (the proxy strips the prefix and sends X-Forwarded-Prefix). Tell the user the URL and that the link is public.`
166
- : 'The directory is served as-is; relative asset paths work, root-absolute ones do not. Tell the user the URL and that the link is public.',
174
+ hint: existing
175
+ ? `This conversation already publishes this ${parsed.port ? 'port' : 'directory'}: same link as before, label unchanged. ${hint}`
176
+ : hint,
167
177
  };
168
178
  } catch (error) {
169
179
  return { ok: false, error: String(error?.message || error) };
@@ -37,6 +37,7 @@ export const REMOTE_RELAY_ACTIONS = Object.freeze([
37
37
  'usage',
38
38
  'list_sessions',
39
39
  'read_session',
40
+ 'read_task_output',
40
41
  'wait',
41
42
  'send',
42
43
  'create_session',
@@ -63,6 +64,7 @@ export const REMOTE_RELAY_ACTION_PERMISSION = Object.freeze({
63
64
  usage: 'read',
64
65
  list_sessions: 'read',
65
66
  read_session: 'read',
67
+ read_task_output: 'read',
66
68
  wait: 'read',
67
69
  send: 'prompt',
68
70
  create_session: 'full',
@@ -120,6 +122,11 @@ export const REMOTE_RELAY_LIMITS = Object.freeze({
120
122
  listMax: 100,
121
123
  readDefault: 10,
122
124
  readMax: 50,
125
+ // read_task_output: lines of a background task's output, and the most
126
+ // text one call returns.
127
+ taskOutputLinesDefault: 200,
128
+ taskOutputLinesMax: 500,
129
+ taskOutputBytesMax: 32 * 1024,
123
130
  maxCharsDefault: 12000,
124
131
  maxCharsMax: 50000,
125
132
  perMessageChars: 4000,
@@ -217,7 +224,9 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
217
224
  + 'with cwd, provider "claude-cloud" with repo and an optional branch), then wait for and read '
218
225
  + 'each one. The first create_session here shows the user an approval card once per '
219
226
  + 'conversation. A session you created cannot create sessions itself, and every session '
220
- + 'runs on the relay owner\'s accounts, so start only what the task needs. A new session '
227
+ + 'runs on the relay owner\'s accounts, so start only what the task needs. Sessions you start '
228
+ + 'here are grouped under this session in the owner\'s sidebar; list_sessions and read_session '
229
+ + 'show the group (read-only for agents, the owner arranges groups). A new session '
221
230
  + 'knows nothing of this conversation: write a self-contained prompt, and for '
222
231
  + '"claude-cloud" (a sandbox with a fresh clone of the repository) name the branch to '
223
232
  + 'work on and to push. '
@@ -233,6 +242,9 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
233
242
  + 'usage reports what the provider plans of a relay have left (Copilot, Claude, Claude Cloud, Cursor, Grok: '
234
243
  + 'meters with used, allowance, remaining, utilization and reset time; providers not set up or not tracked say so) '
235
244
  + 'and whether a Claude usage limit has paused turns there, before you start work that costs. '
245
+ + 'read_task_output {session, task_id} reads the end of a background shell or Monitor task\'s output there '
246
+ + '(task ids from read_session\'s tasks; also for an hour after the task ended): the last lines, minutes '
247
+ + 'since its last output, and pass/fail counts when it is a test run; credentials are scrubbed. '
236
248
  + 'Event subscriptions: subscribe to sessions of this relay or of a paired relay (ids, or "all" for every '
237
249
  + 'session there, later ones too) and the relay wakes you with a message when something happens there: '
238
250
  + 'question_asked (with the question and its options), error (failed turn, usage limit, worker died), '
@@ -261,7 +273,8 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
261
273
  type: 'string',
262
274
  enum: [...REMOTE_RELAY_ACTIONS],
263
275
  description: 'list_relays: the relays (this one included when allowed) and whether each is unlocked here. relay_info: providers, models, '
264
- + 'workspaces of a relay. usage: what each provider\'s plan has left there and any usage-limit pause. list_sessions / read_session: discover and read sessions. send: prompt an '
276
+ + 'workspaces of a relay. usage: what each provider\'s plan has left there and any usage-limit pause. list_sessions / read_session: discover and read sessions. '
277
+ + 'read_task_output: the end of a background shell task\'s output. send: prompt an '
265
278
  + 'existing session. create_session: start a new session with a first prompt. wait: keep waiting for '
266
279
  + 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
267
280
  + 'archive: archive a session. subscribe / unsubscribe / list_subscriptions / transfer_subscriptions: '
@@ -369,6 +382,13 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
369
382
  subscription_id: { type: 'string', description: 'unsubscribe: the id subscribe returned, or "all".' },
370
383
  watcher: { type: 'string', description: 'list_subscriptions: another conversation\'s id, to see its subscriptions (default: this one).' },
371
384
  from: { type: 'string', description: 'transfer_subscriptions: the id of the conversation whose subscriptions move here.' },
385
+ task_id: { type: 'string', description: 'read_task_output: the background task\'s id (taskId in read_session\'s tasks).' },
386
+ lines: {
387
+ type: 'integer',
388
+ minimum: 1,
389
+ maximum: REMOTE_RELAY_LIMITS.taskOutputLinesMax,
390
+ description: `read_task_output: how many of the last output lines (default ${REMOTE_RELAY_LIMITS.taskOutputLinesDefault}).`,
391
+ },
372
392
  },
373
393
  required: ['action'],
374
394
  });
@@ -388,7 +408,7 @@ function invalid(error) {
388
408
  return { ok: false, code: REMOTE_RELAY_ERROR_CODES.invalidInput, error };
389
409
  }
390
410
 
391
- const SESSION_ACTIONS = new Set(['read_session', 'wait', 'send', 'stop', 'archive']);
411
+ const SESSION_ACTIONS = new Set(['read_session', 'read_task_output', 'wait', 'send', 'stop', 'archive']);
392
412
 
393
413
  /**
394
414
  * The longest wait a relay may be set to allow, from whatever was stored or
@@ -493,6 +513,18 @@ export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}
493
513
  args.max_chars = maxChars;
494
514
  args.include_activity = input?.include_activity === true || toText(input?.include_activity).toLowerCase() === 'true';
495
515
  optionalText('before', 1000);
516
+ } else if (action === 'read_task_output') {
517
+ const taskId = toText(input?.task_id);
518
+ if (!taskId) return invalid('read_task_output needs task_id (a taskId from read_session\'s tasks)');
519
+ if (taskId.length > 200 || /[\s/\\]/.test(taskId)) return invalid('task_id is not a task id');
520
+ args.task_id = taskId;
521
+ const lines = clampInteger(input?.lines, {
522
+ min: 1,
523
+ max: REMOTE_RELAY_LIMITS.taskOutputLinesMax,
524
+ fallback: REMOTE_RELAY_LIMITS.taskOutputLinesDefault,
525
+ });
526
+ if (lines === null) return invalid('lines must be a number');
527
+ args.lines = lines;
496
528
  } else if (action === 'wait') {
497
529
  const messageId = toText(input?.message_id);
498
530
  if (!messageId) return invalid('wait needs message_id (from send or create_session)');
@@ -0,0 +1,30 @@
1
+ // What a shared conversation shows of the relay host's paths and file names.
2
+ // One relay-wide setting; the owner's own view is never filtered. Shared so the
3
+ // settings route, the share routes and the Settings control agree.
4
+
5
+ export const SHARE_PATH_PRIVACY_SETTING_KEY = 'share_path_privacy';
6
+
7
+ export const SHARE_PATH_PRIVACY_MODES = Object.freeze(['show', 'filename', 'hide']);
8
+ export const DEFAULT_SHARE_PATH_PRIVACY_MODE = 'filename';
9
+
10
+ export const SHARE_PATH_PRIVACY_LABELS = Object.freeze({
11
+ show: 'Show every path and file name',
12
+ filename: 'Hide the path, show the file name',
13
+ hide: 'Hide paths and file names',
14
+ });
15
+
16
+ function asMode(value) {
17
+ const mode = String(value ?? '').trim().toLowerCase();
18
+ return SHARE_PATH_PRIVACY_MODES.includes(mode) ? mode : null;
19
+ }
20
+
21
+ /** An unset or unreadable setting means the default, never "show". */
22
+ export function readSharePathPrivacySetting(storedValue) {
23
+ return asMode(storedValue) || DEFAULT_SHARE_PATH_PRIVACY_MODE;
24
+ }
25
+
26
+ export function parseSharePathPrivacyUpdate(value) {
27
+ const mode = asMode(value);
28
+ if (!mode) return { ok: false, error: `mode must be one of ${SHARE_PATH_PRIVACY_MODES.join(', ')}` };
29
+ return { ok: true, mode };
30
+ }