@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
@@ -2,12 +2,18 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
4
  import { resolveOarRoot } from './oar-state-migration-service.mjs';
5
+ import { resolveLayoutStateDirs, resolveRelayStateDirs } from './oar-state-layout.mjs';
5
6
 
6
7
  export const WINDOWS_AUTOSTART_FILENAME = 'oar-web-relay.cmd';
7
8
  // The pre-rebrand name. Never written anymore, but still recognized (an install
8
9
  // that enabled autostart before the OAR rename has this file) and cleaned up on
9
10
  // every state change so the two names can never both launch a relay.
10
11
  export const LEGACY_WINDOWS_AUTOSTART_FILENAME = 'copilot-remote-web-relay.cmd';
12
+ // The launchers' wait, in all, for %TEMP% and the data drive (autostartWaitLines).
13
+ export const AUTOSTART_WAIT_SECONDS = 300;
14
+ // The sign-in entry's wait log, in the OAR state root: Windows opens every
15
+ // file in the Startup folder at sign-in.
16
+ export const WINDOWS_AUTOSTART_LOG_FILENAME = 'oar-web-relay.log';
11
17
 
12
18
  export function escapeBatchValue(value) {
13
19
  return String(value || '').replaceAll('%', '%%');
@@ -26,41 +32,305 @@ export function resolveWindowsStartupDirectory(env = process.env, pathImpl = pat
26
32
  return pathImpl.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
27
33
  }
28
34
 
35
+ /** The `set` lines that pin the relay's data and log folders (shared with the boot launcher). */
36
+ export function stateDirLines({ dataDir = '', logDir = '', pathImpl = path } = {}) {
37
+ const lines = [];
38
+ if (String(dataDir || '').trim()) {
39
+ lines.push(`set "COPILOT_WEB_RELAY_DATA_DIR=${escapeBatchValue(pathImpl.resolve(String(dataDir).trim()))}"`);
40
+ }
41
+ if (String(logDir || '').trim()) {
42
+ lines.push(`set "COPILOT_WEB_RELAY_LOG_DIR=${escapeBatchValue(pathImpl.resolve(String(logDir).trim()))}"`);
43
+ }
44
+ return lines;
45
+ }
46
+
47
+ /**
48
+ * The data folder's drive root (`R:\`), '' for none or a UNC path (cmd's
49
+ * `if exist` does not tell a share root reliably). The root, not the folder:
50
+ * on a first run the relay creates the folder.
51
+ */
52
+ export function dataDriveRoot(dataDir = '', pathImpl = path) {
53
+ const trimmed = String(dataDir || '').trim();
54
+ if (!trimmed) return '';
55
+ const { root } = pathImpl.parse(pathImpl.resolve(trimmed));
56
+ return /^[A-Za-z]:[\\/]$/.test(root) ? `${root.slice(0, 2).toUpperCase()}\\` : '';
57
+ }
58
+
59
+ /**
60
+ * Before Node starts: up to AUTOSTART_WAIT_SECONDS in all for %TEMP% and the
61
+ * data drive; when it runs out, one line naming what is missing goes to
62
+ * `logTarget` and Node starts anyway. A round is about a second: `ping -n 2`
63
+ * waits one between its echoes (by full path: an early boot's PATH may lack
64
+ * System32). TEMP is read once, into OAR_TEMP without quotes, only
65
+ * when set: `if not "%TEMP%"==""` breaks on `"D:\My Temp"`, an unset
66
+ * `%TEMP:"=%` expands to `"=`. `waitSeconds`: tests only.
67
+ */
68
+ export function autostartWaitLines({ dataDir = '', logTarget, pathImpl = path, waitSeconds = AUTOSTART_WAIT_SECONDS } = {}) {
69
+ const drive = escapeBatchValue(dataDriveRoot(dataDir, pathImpl));
70
+ const missing = (temp, data) => [temp && 'TEMP folder "%OAR_TEMP%"', data && `data drive "${drive}"`].filter(Boolean).join(' and ');
71
+ const logLine = (text) => `echo [%DATE% %TIME%] Still missing after ${waitSeconds} s: ${text}; starting the relay anyway.>>${logTarget}`;
72
+ const lines = [
73
+ 'set "OAR_TEMP="',
74
+ 'if not defined TEMP goto oar_temp_set',
75
+ 'set "OAR_TEMP=%TEMP:"=%"',
76
+ ':oar_temp_set',
77
+ 'set /a OAR_WAIT=0',
78
+ ':oar_wait',
79
+ 'set "OAR_WAIT_TEMP="',
80
+ 'if defined OAR_TEMP if not exist "%OAR_TEMP%\\" set "OAR_WAIT_TEMP=1"',
81
+ ];
82
+ if (drive) {
83
+ lines.push(
84
+ 'set "OAR_WAIT_DATA="',
85
+ `if not exist "${drive}" set "OAR_WAIT_DATA=1"`,
86
+ 'if not defined OAR_WAIT_TEMP if not defined OAR_WAIT_DATA goto oar_start',
87
+ );
88
+ } else {
89
+ lines.push('if not defined OAR_WAIT_TEMP goto oar_start');
90
+ }
91
+ lines.push(
92
+ `if %OAR_WAIT% geq ${waitSeconds} goto oar_wait_over`,
93
+ '"%SystemRoot%\\System32\\ping.exe" -n 2 127.0.0.1 >nul',
94
+ 'set /a OAR_WAIT+=1',
95
+ 'goto oar_wait',
96
+ ':oar_wait_over',
97
+ );
98
+ // One line, whichever is missing: exactly one of these runs.
99
+ if (drive) {
100
+ lines.push(
101
+ `if defined OAR_WAIT_TEMP if defined OAR_WAIT_DATA ${logLine(missing(true, true))}`,
102
+ `if defined OAR_WAIT_TEMP if not defined OAR_WAIT_DATA ${logLine(missing(true, false))}`,
103
+ `if not defined OAR_WAIT_TEMP if defined OAR_WAIT_DATA ${logLine(missing(false, true))}`,
104
+ );
105
+ } else {
106
+ lines.push(logLine(missing(true, false)));
107
+ }
108
+ // The copy and the counters are not handed on to the relay.
109
+ lines.push(':oar_start', 'set "OAR_WAIT="', 'set "OAR_WAIT_TEMP="');
110
+ if (drive) lines.push('set "OAR_WAIT_DATA="');
111
+ lines.push('set "OAR_TEMP="');
112
+ return lines;
113
+ }
114
+
115
+ // How far past the end of the file it replaces the padding reaches, and the
116
+ // longest padding line (cmd.exe refuses lines over 8191 characters).
117
+ export const LAUNCHER_PADDING_MARGIN = 64;
118
+ // What the padding covers at least, whatever the file replaced: an entry
119
+ // written from scratch (autostart switched off and on) may still have the
120
+ // cmd of an earlier one running. Unpadded formats stay under 2 KB; a padded
121
+ // one's Node line ends it, so its cmd never reads on.
122
+ export const LAUNCHER_MIN_COVER_BYTES = 4096;
123
+ const LAUNCHER_PADDING_LINE_MAX = 8000;
124
+ // cmd.exe reads a batch file in the console's OEM code page; the file is
125
+ // UTF-8, so a path with non-ASCII characters (a user profile) would be
126
+ // garbled without this. By full path: an early boot's PATH may lack System32.
127
+ export const LAUNCHER_CODE_PAGE_LINE = '"%SystemRoot%\\System32\\chcp.com" 65001 >nul';
128
+
129
+ /**
130
+ * Every launcher OAR writes has this shape, so it can be replaced while a
131
+ * cmd.exe still runs the one before it. cmd does not keep a batch file open:
132
+ * after each command it opens the file by name again and reads on from the
133
+ * byte offset it had reached, and the relay's cmd waits on the Node line for
134
+ * as long as the relay runs. Read on in a longer replacement, that offset
135
+ * would land on a line before the Node line and start the relay a second
136
+ * time.
137
+ *
138
+ * <header lines>
139
+ * goto oar_begin
140
+ * <spaces, up to past the end of the file this one replaces>
141
+ * exit /b
142
+ * :oar_begin
143
+ * ...
144
+ * "node.exe" "...server.js" & exit
145
+ *
146
+ * A fresh run jumps over the padding. A cmd still running the file replaced
147
+ * reads on at its offset, inside the padding, finds blanks and then
148
+ * `exit /b`. And since the Node line ends the script itself (cmd has read the
149
+ * whole line before Node starts), a cmd running this file never reads on in
150
+ * a later replacement at all. `coverBytes` is the size of the file being
151
+ * replaced (at least
152
+ * LAUNCHER_MIN_COVER_BYTES is covered).
153
+ */
154
+ export function buildLauncherFile({ headerLines = ['@echo off'], bodyLines = [], coverBytes = 0 } = {}) {
155
+ const head = [...headerLines, 'goto oar_begin'];
156
+ const headBytes = Buffer.byteLength(head.map((line) => `${line}\r\n`).join(''), 'utf8');
157
+ let remaining = Math.max(
158
+ LAUNCHER_PADDING_MARGIN,
159
+ Math.max(Number(coverBytes) || 0, LAUNCHER_MIN_COVER_BYTES) + LAUNCHER_PADDING_MARGIN - headBytes,
160
+ );
161
+ const padding = [];
162
+ while (remaining > 0) {
163
+ const width = Math.min(remaining, LAUNCHER_PADDING_LINE_MAX);
164
+ padding.push(' '.repeat(width));
165
+ remaining -= width + 2;
166
+ }
167
+ return [...head, ...padding, 'exit /b', ':oar_begin', LAUNCHER_CODE_PAGE_LINE, ...bodyLines, ''].join('\r\n');
168
+ }
169
+
170
+ /**
171
+ * The line that starts the relay and ends the script (buildLauncherFile).
172
+ * `exit` hands on Node's exit code (`exit /b`, the earlier `ending`, gave
173
+ * `cmd /c` 0). It closes no one's console: Task Scheduler and the Startup
174
+ * folder each start a cmd of their own for a launcher.
175
+ */
176
+ export function launcherNodeLine(nodePath, serverPath, { ending = 'exit' } = {}) {
177
+ return `"${escapeBatchValue(nodePath)}" "${escapeBatchValue(serverPath)}" & ${ending}`;
178
+ }
179
+
180
+ /**
181
+ * The Node a launcher runs `serverPath` with, read from its Node line (with or
182
+ * without the trailing `& exit` or `& exit /b`); null when it runs no such
183
+ * server.
184
+ */
185
+ export function readLauncherNodePath(script, serverPath) {
186
+ const wanted = escapeBatchValue(serverPath).toLowerCase();
187
+ for (const line of String(script == null ? '' : script).split(/\r?\n/)) {
188
+ const match = line.trimEnd().match(/^"([^"]+)" "([^"]+)"(?: & exit(?: \/b)?)?$/);
189
+ if (match && match[2].toLowerCase() === wanted) return match[1].replaceAll('%%', '%');
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * The package folder of another OAR install a launcher starts (its Node line
196
+ * runs a `server\server.js` other than `packageRoot`'s), or null.
197
+ */
198
+ export function otherInstallOf(script, packageRoot, pathImpl = path) {
199
+ const own = pathImpl.resolve(String(packageRoot || '')).toLowerCase();
200
+ for (const line of String(script == null ? '' : script).split(/\r?\n/)) {
201
+ const match = line.trimEnd().match(/^"[^"]+" "([^"]+)[\\/]server[\\/]server\.js"(?: & exit(?: \/b)?)?$/i);
202
+ if (match && pathImpl.resolve(match[1].replaceAll('%%', '%')).toLowerCase() !== own) return match[1].replaceAll('%%', '%');
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Where a copy of `target` goes before `--replace` writes over or removes
209
+ * it: `<name>.bak` in `dir`, numbered when one is there already, so no
210
+ * earlier copy is lost.
211
+ */
212
+ export function backupPathFor(target, dir, fsImpl = fs, pathImpl = path) {
213
+ const base = pathImpl.join(dir, `${pathImpl.basename(target)}.bak`);
214
+ let candidate = base;
215
+ for (let index = 2; fsImpl.existsSync(candidate); index += 1) candidate = `${base}-${index}`;
216
+ return candidate;
217
+ }
218
+
219
+ /**
220
+ * Same launcher, whatever the line endings, trailing blanks and the length
221
+ * of the padding. `ignoreCase` for telling whether a launcher is one an
222
+ * earlier version generated: Windows paths compare without case.
223
+ */
224
+ export function launcherScriptsMatch(actual, expected, { ignoreCase = false } = {}) {
225
+ const normalize = (value) => {
226
+ const text = String(value == null ? '' : value)
227
+ .split(/\r?\n/)
228
+ .map((line) => line.replace(/\s+$/, ''))
229
+ .filter(Boolean)
230
+ .join('\n');
231
+ return ignoreCase ? text.toLowerCase() : text;
232
+ };
233
+ return normalize(actual) === normalize(expected);
234
+ }
235
+
236
+ function requireLauncherInputs({ packageRoot, nodePath }, what) {
237
+ if (!String(packageRoot || '').trim()) {
238
+ throw new Error(`${what} requires the copilot-remote package root.`);
239
+ }
240
+ if (!String(nodePath || '').trim()) {
241
+ throw new Error(`${what} requires the Node.js executable path.`);
242
+ }
243
+ }
244
+
29
245
  export function buildWindowsAutostartScript({
30
246
  packageRoot,
31
247
  nodePath = process.execPath,
32
248
  configPath = '',
249
+ // The data and log folders `oar start` gives the relay
250
+ // (resolveRelayStateDirs); without them the relay would keep its database
251
+ // inside the package directory, which every update replaces.
252
+ dataDir = '',
253
+ logDir = '',
33
254
  // On Windows a process pins its cwd, and self-update must be able to
34
255
  // replace the package directory — so the relay runs from the OAR state
35
256
  // root, not from the code it executes.
36
257
  workingDirectory = '',
258
+ // Size of the file this one replaces (see buildLauncherFile).
259
+ coverBytes = 0,
37
260
  pathImpl = path,
38
261
  } = {}) {
39
- const resolvedPackageRoot = pathImpl.resolve(String(packageRoot || ''));
40
- if (!String(packageRoot || '').trim()) {
41
- throw new Error('Windows autostart requires the copilot-remote package root.');
42
- }
43
- const resolvedNodePath = pathImpl.resolve(String(nodePath || ''));
44
- if (!String(nodePath || '').trim()) {
45
- throw new Error('Windows autostart requires the Node.js executable path.');
46
- }
262
+ return buildLauncherFile({
263
+ bodyLines: signInBodyLines({
264
+ packageRoot, nodePath, configPath, dataDir, logDir, workingDirectory, pathImpl, wait: true,
265
+ }),
266
+ coverBytes,
267
+ });
268
+ }
269
+
270
+ // The entry's lines after the padding; `wait: false` is the earlier form.
271
+ function signInBodyLines({ packageRoot, nodePath, configPath, dataDir, logDir, workingDirectory, pathImpl, wait }) {
272
+ requireLauncherInputs({ packageRoot, nodePath }, 'Windows autostart');
273
+ const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
47
274
  const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
48
- const lines = [
49
- '@echo off',
50
- 'title OAR Web Relay',
51
- ];
275
+ const lines = ['title OAR Web Relay'];
52
276
  if (String(configPath || '').trim()) {
53
277
  lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
54
278
  }
279
+ lines.push(...stateDirLines({ dataDir, logDir, pathImpl }));
55
280
  const cwdTarget = String(workingDirectory || '').trim()
56
281
  ? pathImpl.resolve(String(workingDirectory))
57
282
  : resolvedPackageRoot;
283
+ if (wait) {
284
+ const logTarget = `"${escapeBatchValue(pathImpl.join(cwdTarget, WINDOWS_AUTOSTART_LOG_FILENAME))}"`;
285
+ lines.push(...autostartWaitLines({ dataDir, logTarget, pathImpl }));
286
+ }
58
287
  lines.push(
59
288
  `cd /d "${escapeBatchValue(cwdTarget)}"`,
60
- `"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
61
- '',
289
+ launcherNodeLine(pathImpl.resolve(String(nodePath)), serverPath, wait ? {} : { ending: 'exit /b' }),
62
290
  );
63
- return lines.join('\r\n');
291
+ return lines;
292
+ }
293
+
294
+ /**
295
+ * The sign-in entries earlier versions generated, newest first: the padded
296
+ * one before 0.9.14 shipped (no wait, `& exit /b`; needs `dataDir` and
297
+ * `logDir`), 0.9.2 to 0.9.13, then 0.9.0 and 0.9.1 (pre-rebrand name).
298
+ */
299
+ export function buildEarlierWindowsAutostartScripts({
300
+ packageRoot,
301
+ nodePath,
302
+ configPath = '',
303
+ dataDir = '',
304
+ logDir = '',
305
+ workingDirectory = '',
306
+ pathImpl = path,
307
+ } = {}) {
308
+ requireLauncherInputs({ packageRoot, nodePath }, 'Windows autostart');
309
+ const resolvedPackageRoot = pathImpl.resolve(String(packageRoot));
310
+ const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
311
+ const unpadded = (title, cwd) => [
312
+ '@echo off',
313
+ title,
314
+ ...(String(configPath || '').trim()
315
+ ? [`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`]
316
+ : []),
317
+ `cd /d "${escapeBatchValue(cwd)}"`,
318
+ `"${escapeBatchValue(pathImpl.resolve(String(nodePath)))}" "${escapeBatchValue(serverPath)}"`,
319
+ '',
320
+ ].join('\r\n');
321
+ const scripts = [];
322
+ if (String(workingDirectory || '').trim()) {
323
+ scripts.push(
324
+ buildLauncherFile({
325
+ bodyLines: signInBodyLines({
326
+ packageRoot, nodePath, configPath, dataDir, logDir, workingDirectory, pathImpl, wait: false,
327
+ }),
328
+ }),
329
+ unpadded('title OAR Web Relay', pathImpl.resolve(String(workingDirectory))),
330
+ );
331
+ }
332
+ scripts.push(unpadded('title Copilot Remote Web Relay', resolvedPackageRoot));
333
+ return scripts;
64
334
  }
65
335
 
66
336
  export function createWindowsAutostartService({
@@ -101,14 +371,111 @@ export function createWindowsAutostartService({
101
371
  };
102
372
  }
103
373
 
104
- function removeLegacyEntry() {
105
- const target = legacyEntryPath();
106
- if (!fsImpl.existsSync(target)) return false;
107
- fsImpl.unlinkSync(target);
108
- return true;
374
+ const workingDirectory = () => resolveOarRoot(env, platform);
375
+ const serverPath = () => pathImpl.join(pathImpl.resolve(String(packageRoot || '')), 'server', 'server.js');
376
+
377
+ function buildScript({ stateDirs, coverBytes = 0, nodePathOverride = null } = {}) {
378
+ return buildWindowsAutostartScript({
379
+ packageRoot,
380
+ nodePath: nodePathOverride || nodePath,
381
+ configPath,
382
+ ...(stateDirs || resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl })),
383
+ workingDirectory: workingDirectory(),
384
+ coverBytes,
385
+ pathImpl,
386
+ });
109
387
  }
110
388
 
111
- function setEnabled(enabled) {
389
+ function writeEntry(targetPath, script) {
390
+ fsImpl.mkdirSync(pathImpl.dirname(targetPath), { recursive: true });
391
+ const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
392
+ try {
393
+ fsImpl.writeFileSync(temporaryPath, script, { encoding: 'utf8', flag: 'wx' });
394
+ fsImpl.renameSync(temporaryPath, targetPath);
395
+ } finally {
396
+ if (fsImpl.existsSync(temporaryPath)) fsImpl.unlinkSync(temporaryPath);
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Brings an entry an earlier version wrote (see
402
+ * buildEarlierWindowsAutostartScripts) up to date, under the name it has;
403
+ * the relay calls this once it runs, so an update or `oar start` repairs the
404
+ * entry without the setting being switched off and on. Only an entry exactly
405
+ * as that version wrote it for this install and its config is rewritten,
406
+ * keeping the Node it runs; one edited by hand, or one of another install
407
+ * of OAR (the Startup entry has one name for all of them), is left alone. It
408
+ * pins only the install's own folders (resolveLayoutStateDirs), so nothing
409
+ * happens in a checkout or on a relay started with folders of its own. True
410
+ * when it rewrote an entry.
411
+ */
412
+ // `onEarlierForm`: the earlier form found (`pinnedData` false: package database).
413
+ function refreshInstalledEntry({ onEarlierForm } = {}) {
414
+ if (!supported || String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '').trim()) return false;
415
+ const stateDirs = resolveLayoutStateDirs({ packageRoot, env, platform, fsImpl, pathImpl });
416
+ if (!stateDirs) return false;
417
+ let changed = false;
418
+ for (const targetPath of [entryPath(), legacyEntryPath()]) {
419
+ if (!fsImpl.existsSync(targetPath)) continue;
420
+ const current = String(fsImpl.readFileSync(targetPath, 'utf8'));
421
+ const entryNodePath = readLauncherNodePath(current, serverPath());
422
+ if (!entryNodePath) continue;
423
+ const earlier = buildEarlierWindowsAutostartScripts({
424
+ packageRoot, nodePath: entryNodePath, configPath, ...stateDirs, workingDirectory: workingDirectory(), pathImpl,
425
+ });
426
+ const matched = earlier.find((script) => launcherScriptsMatch(current, script, { ignoreCase: true }));
427
+ if (!matched) continue;
428
+ onEarlierForm?.({ path: targetPath, pinnedData: matched.includes('COPILOT_WEB_RELAY_DATA_DIR') });
429
+ writeEntry(targetPath, buildScript({
430
+ stateDirs,
431
+ nodePathOverride: entryNodePath,
432
+ coverBytes: Buffer.byteLength(current, 'utf8'),
433
+ }));
434
+ changed = true;
435
+ }
436
+ return changed;
437
+ }
438
+
439
+ /**
440
+ * Whether `script` is an entry OAR generates or generated for this install
441
+ * and its config (with whatever Node it runs). Anything else, an entry
442
+ * edited by hand or one of another install, is hand-made.
443
+ */
444
+ function isGenerated(script) {
445
+ const entryNodePath = readLauncherNodePath(script, serverPath());
446
+ if (!entryNodePath) return false;
447
+ const stateDirs = resolveRelayStateDirs({ packageRoot, env, platform, fsImpl, pathImpl });
448
+ return launcherScriptsMatch(script, buildScript({ stateDirs, nodePathOverride: entryNodePath }))
449
+ || buildEarlierWindowsAutostartScripts({
450
+ packageRoot, nodePath: entryNodePath, configPath, ...stateDirs, workingDirectory: workingDirectory(), pathImpl,
451
+ }).some((earlier) => launcherScriptsMatch(script, earlier, { ignoreCase: true }));
452
+ }
453
+
454
+ /**
455
+ * The sign-in entries there are, each `{ path, generated, otherInstall }`:
456
+ * `otherInstall` is the package folder of the other OAR install a
457
+ * hand-made entry starts (an entry left from a moved or second install).
458
+ */
459
+ function listEntries() {
460
+ if (!supported) return [];
461
+ return [entryPath(), legacyEntryPath()]
462
+ .filter((target) => fsImpl.existsSync(target))
463
+ .map((target) => {
464
+ const script = String(fsImpl.readFileSync(target, 'utf8'));
465
+ const generated = isGenerated(script);
466
+ return { path: target, generated, otherInstall: generated ? null : otherInstallOf(script, packageRoot, pathImpl) };
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Switches the sign-in entry on or off. Only entries OAR generated are
472
+ * written over or removed: a hand-made one stays byte-identical and is
473
+ * returned in `kept`, and while one is there no second entry is added.
474
+ * `replace` (with `backupDir`, never inside the Startup folder, which
475
+ * would open the copy at sign-in) first moves each hand-made entry there
476
+ * as `<name>.bak`, listed in `backedUp`.
477
+ */
478
+ function setEnabled(enabled, { replace = false, backupDir = '' } = {}) {
112
479
  if (!supported) {
113
480
  throw new Error('Windows autostart is only available on Windows.');
114
481
  }
@@ -116,41 +483,44 @@ export function createWindowsAutostartService({
116
483
  throw new TypeError('Windows autostart enabled state must be a boolean.');
117
484
  }
118
485
 
119
- const targetPath = entryPath();
120
- const existed = fsImpl.existsSync(targetPath);
486
+ const backedUp = [];
487
+ if (replace) {
488
+ if (!String(backupDir || '').trim()) throw new Error('Replacing a hand-made entry needs a folder for its copy.');
489
+ for (const entry of listEntries().filter((listed) => !listed.generated)) {
490
+ fsImpl.mkdirSync(backupDir, { recursive: true });
491
+ const copy = backupPathFor(entry.path, backupDir, fsImpl, pathImpl);
492
+ fsImpl.copyFileSync(entry.path, copy);
493
+ fsImpl.unlinkSync(entry.path);
494
+ backedUp.push({ path: entry.path, backup: copy });
495
+ }
496
+ }
497
+ const entries = listEntries();
498
+ const kept = entries.filter((entry) => !entry.generated).map((entry) => entry.path);
499
+ const remove = (entry) => { fsImpl.unlinkSync(entry.path); return true; };
121
500
  if (!enabled) {
122
- const removedLegacy = removeLegacyEntry();
123
- if (existed) fsImpl.unlinkSync(targetPath);
124
- return { ...getState(), changed: existed || removedLegacy };
501
+ const removed = entries.filter((entry) => entry.generated).map(remove);
502
+ return { ...getState(), changed: removed.length + backedUp.length > 0, kept, backedUp };
125
503
  }
504
+ if (kept.length) return { ...getState(), changed: false, kept, backedUp };
126
505
  // Enabling always converges on the one current filename; if only the
127
506
  // legacy entry existed, the current-name write below reports the change.
128
- removeLegacyEntry();
507
+ entries.filter((entry) => entry.path === legacyEntryPath()).forEach(remove);
129
508
 
130
- const script = buildWindowsAutostartScript({
131
- packageRoot,
132
- nodePath,
133
- configPath,
134
- workingDirectory: resolveOarRoot(env, platform),
135
- pathImpl,
136
- });
137
- if (existed && fsImpl.readFileSync(targetPath, 'utf8') === script) {
138
- return { ...getState(), changed: false };
509
+ const targetPath = entryPath();
510
+ const existed = fsImpl.existsSync(targetPath);
511
+ const current = existed ? String(fsImpl.readFileSync(targetPath, 'utf8')) : '';
512
+ if (existed && launcherScriptsMatch(current, buildScript())) {
513
+ return { ...getState(), changed: backedUp.length > 0, kept, backedUp };
139
514
  }
140
515
 
141
- fsImpl.mkdirSync(pathImpl.dirname(targetPath), { recursive: true });
142
- const temporaryPath = `${targetPath}.${randomUUID()}.tmp`;
143
- try {
144
- fsImpl.writeFileSync(temporaryPath, script, { encoding: 'utf8', flag: 'wx' });
145
- fsImpl.renameSync(temporaryPath, targetPath);
146
- } finally {
147
- if (fsImpl.existsSync(temporaryPath)) fsImpl.unlinkSync(temporaryPath);
148
- }
149
- return { ...getState(), changed: true };
516
+ writeEntry(targetPath, buildScript({ coverBytes: Buffer.byteLength(current, 'utf8') }));
517
+ return { ...getState(), changed: true, kept, backedUp };
150
518
  }
151
519
 
152
520
  return {
153
521
  getState,
522
+ listEntries,
154
523
  setEnabled,
524
+ refreshInstalledEntry,
155
525
  };
156
526
  }