@oar-sh/oar 0.9.1 → 0.9.3

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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -0,0 +1,220 @@
1
+ 'use strict';
2
+
3
+ import { channelForVersion, compareSemverIsh, parseSemverIsh } from '../../shared/update-semver.mjs';
4
+
5
+ // Polls oar.sh/latest.json for new OAR releases. Zero-telemetry promise:
6
+ // automatic checking is OPT-IN (off unless the stored setting says '1'), so a
7
+ // default install never contacts oar.sh — only the settings toggle or an
8
+ // explicit "check now" triggers a request. OAR_NO_UPDATE_CHECK=1 is the hard
9
+ // kill switch (tests, air-gapped hosts): the runtime then never constructs
10
+ // this service, so not even manual checks can reach out.
11
+
12
+ export const UPDATE_MANIFEST_URL_DEFAULT = 'https://oar.sh/latest.json';
13
+ export const UPDATE_CHECK_INTERVAL_MS = 12 * 3_600_000;
14
+ export const UPDATE_CHECK_JITTER_MS = 3_600_000;
15
+ export const UPDATE_FETCH_TIMEOUT_MS = 5_000;
16
+
17
+ export const UPDATE_AUTO_CHECK_SETTING_KEY = 'update_auto_check_enabled';
18
+ const ETAG_SETTING_KEY = 'update_check_etag';
19
+ const RESULT_SETTING_KEY = 'update_check_result';
20
+ const DISMISSED_SETTING_KEY = 'update_dismissed_version';
21
+
22
+ export function isUpdateCheckKilled(env = process.env) {
23
+ return String(env?.OAR_NO_UPDATE_CHECK || '').trim() === '1';
24
+ }
25
+
26
+ export function resolveUpdateManifestUrl(env = process.env) {
27
+ return String(env?.OAR_UPDATE_MANIFEST_URL || '').trim() || UPDATE_MANIFEST_URL_DEFAULT;
28
+ }
29
+
30
+ export function createUpdateCheckService({
31
+ runningVersion,
32
+ installMethod = 'npm-global',
33
+ manifestUrl = UPDATE_MANIFEST_URL_DEFAULT,
34
+ fetchImpl = globalThis.fetch,
35
+ readSetting,
36
+ writeSetting,
37
+ setTimeoutImpl = setTimeout,
38
+ clearTimeoutImpl = clearTimeout,
39
+ randomImpl = Math.random,
40
+ nowImpl = Date.now,
41
+ logger = console,
42
+ onStateChange = null,
43
+ } = {}) {
44
+ let timer = null;
45
+ let checkInFlight = null;
46
+ let schemaUnsupported = false;
47
+ let schemaWarned = false;
48
+
49
+ const channel = channelForVersion(runningVersion);
50
+
51
+ const emit = () => {
52
+ try {
53
+ onStateChange?.(getSnapshot());
54
+ } catch {}
55
+ };
56
+
57
+ const readResult = () => {
58
+ try {
59
+ const parsed = JSON.parse(String(readSetting(RESULT_SETTING_KEY) || ''));
60
+ return parsed && typeof parsed === 'object' ? parsed : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
66
+ function isAutoCheckEnabled() {
67
+ return String(readSetting(UPDATE_AUTO_CHECK_SETTING_KEY) || '').trim() === '1';
68
+ }
69
+
70
+ function getSnapshot() {
71
+ const result = readResult();
72
+ const latest = String(result?.version || '');
73
+ const available = !!latest && compareSemverIsh(latest, runningVersion) > 0;
74
+ const critical = result?.critical === true;
75
+ const dismissedVersion = String(readSetting(DISMISSED_SETTING_KEY) || '').trim();
76
+ return {
77
+ autoCheckEnabled: isAutoCheckEnabled(),
78
+ runningVersion,
79
+ installMethod,
80
+ channel,
81
+ available,
82
+ version: available ? latest : null,
83
+ notesUrl: available ? String(result?.notesUrl || '') || null : null,
84
+ publishedAt: available ? String(result?.publishedAt || '') || null : null,
85
+ critical: available ? critical : false,
86
+ dismissed: available && !critical && dismissedVersion === latest,
87
+ lastCheckedAt: result?.checkedAt || null,
88
+ schemaUnsupported,
89
+ };
90
+ }
91
+
92
+ function disarm() {
93
+ if (timer) {
94
+ clearTimeoutImpl(timer);
95
+ timer = null;
96
+ }
97
+ }
98
+
99
+ function arm(delayMs) {
100
+ disarm();
101
+ if (schemaUnsupported) return;
102
+ timer = setTimeoutImpl(() => {
103
+ timer = null;
104
+ if (!isAutoCheckEnabled()) return; // opted out since arming; stay quiet
105
+ void checkNow().finally(() => {
106
+ if (isAutoCheckEnabled()) arm(nextDelayMs());
107
+ });
108
+ }, delayMs);
109
+ timer?.unref?.();
110
+ }
111
+
112
+ function nextDelayMs() {
113
+ // ±1h of jitter so a fleet of relays doesn't hit oar.sh in sync.
114
+ return UPDATE_CHECK_INTERVAL_MS + Math.floor((randomImpl() * 2 - 1) * UPDATE_CHECK_JITTER_MS);
115
+ }
116
+
117
+ /** A failed check must never affect anything: all failures resolve silently. */
118
+ async function checkNow() {
119
+ if (schemaUnsupported) return getSnapshot();
120
+ if (checkInFlight) return checkInFlight;
121
+ checkInFlight = (async () => {
122
+ try {
123
+ const headers = { Accept: 'application/json' };
124
+ const etag = String(readSetting(ETAG_SETTING_KEY) || '').trim();
125
+ // Only revalidate when the cached result actually exists — an orphaned
126
+ // ETag row would otherwise 304 forever and report nothing.
127
+ if (etag && readResult()) headers['If-None-Match'] = etag;
128
+ const response = await fetchImpl(manifestUrl, {
129
+ headers,
130
+ redirect: 'follow',
131
+ signal: AbortSignal.timeout?.(UPDATE_FETCH_TIMEOUT_MS),
132
+ });
133
+ if (response.status === 304) {
134
+ const cached = readResult();
135
+ if (cached) {
136
+ writeSetting(RESULT_SETTING_KEY, JSON.stringify({ ...cached, checkedAt: new Date(nowImpl()).toISOString() }));
137
+ }
138
+ return getSnapshot();
139
+ }
140
+ if (!response.ok) return getSnapshot();
141
+ const manifest = await response.json();
142
+ if (!manifest || typeof manifest !== 'object') return getSnapshot();
143
+ if (manifest.schemaVersion !== 1) {
144
+ schemaUnsupported = true;
145
+ disarm();
146
+ if (!schemaWarned) {
147
+ schemaWarned = true;
148
+ logger.warn?.(`update check: latest.json schemaVersion ${manifest.schemaVersion} is unknown to this relay; update checking stopped (update OAR manually)`);
149
+ }
150
+ return getSnapshot();
151
+ }
152
+ const entry = manifest.channels?.[channel] || manifest.channels?.stable;
153
+ if (!entry || !parseSemverIsh(entry.version)) return getSnapshot();
154
+ const responseEtag = String(response.headers?.get?.('etag') || '').trim();
155
+ if (responseEtag) writeSetting(ETAG_SETTING_KEY, responseEtag);
156
+ writeSetting(RESULT_SETTING_KEY, JSON.stringify({
157
+ version: String(entry.version),
158
+ notesUrl: String(entry.notesUrl || ''),
159
+ publishedAt: String(entry.publishedAt || ''),
160
+ critical: entry.critical === true,
161
+ channel,
162
+ checkedAt: new Date(nowImpl()).toISOString(),
163
+ }));
164
+ return getSnapshot();
165
+ } catch {
166
+ return getSnapshot(); // timeout, DNS, JSON garbage — all silent
167
+ } finally {
168
+ checkInFlight = null;
169
+ emit();
170
+ }
171
+ })();
172
+ return checkInFlight;
173
+ }
174
+
175
+ function setAutoCheck(enabled) {
176
+ writeSetting(UPDATE_AUTO_CHECK_SETTING_KEY, enabled ? '1' : '0');
177
+ if (enabled) {
178
+ void checkNow();
179
+ arm(nextDelayMs());
180
+ } else {
181
+ disarm();
182
+ }
183
+ emit();
184
+ return { ok: true, autoCheckEnabled: !!enabled };
185
+ }
186
+
187
+ /** Boot hook: arms the poller only when the user already opted in. */
188
+ function startIfEnabled() {
189
+ if (!isAutoCheckEnabled()) return false;
190
+ void checkNow();
191
+ arm(nextDelayMs());
192
+ return true;
193
+ }
194
+
195
+ function dismissVersion(version) {
196
+ const snapshot = getSnapshot();
197
+ if (!snapshot.available) return { ok: false, error: 'No update to dismiss' };
198
+ if (snapshot.critical) return { ok: false, error: 'This update is marked critical and cannot be dismissed' };
199
+ if (String(version || '').trim() !== snapshot.version) {
200
+ return { ok: false, error: 'Version mismatch' };
201
+ }
202
+ writeSetting(DISMISSED_SETTING_KEY, snapshot.version);
203
+ emit();
204
+ return { ok: true };
205
+ }
206
+
207
+ function stop() {
208
+ disarm();
209
+ }
210
+
211
+ return {
212
+ startIfEnabled,
213
+ setAutoCheck,
214
+ isAutoCheckEnabled,
215
+ checkNow,
216
+ getSnapshot,
217
+ dismissVersion,
218
+ stop,
219
+ };
220
+ }
@@ -0,0 +1,279 @@
1
+ 'use strict';
2
+
3
+ import { spawn as defaultSpawn } from 'child_process';
4
+
5
+ import {
6
+ CLI_SPAWN_DISABLED_ERROR,
7
+ MAX_CAPTURED_OUTPUT_CHARS,
8
+ killTree,
9
+ runToCompletion,
10
+ stripTerminalEscapes,
11
+ tailOf,
12
+ } from './cli-process-runner.mjs';
13
+ import { compareSemverIsh, parseSemverIsh } from '../../shared/update-semver.mjs';
14
+
15
+ // Applies an OAR self-update: `npm i -g @oar-sh/oar@<version>` with the log
16
+ // streamed to the UI (same snapshot/logSeq idiom as cli-install-service, which
17
+ // stays untouched — its descriptor table is provider CLIs only), then the
18
+ // existing queue-idle relay restart. The attempt is persisted BEFORE the
19
+ // restart so the next boot can prove the version actually changed; an
20
+ // unchanged version surfaces the npm log as a failure instead of false
21
+ // success.
22
+
23
+ export const UPDATE_PACKAGE_NAME = '@oar-sh/oar';
24
+ export const UPDATE_ATTEMPT_SETTING_KEY = 'update_attempt';
25
+ export const UPDATE_OUTCOME_SETTING_KEY = 'update_last_outcome';
26
+ export const UPDATE_INSTALL_TIMEOUT_MS = 10 * 60_000;
27
+ const LOG_FLUSH_MS = 250;
28
+ const ATTEMPT_LOG_TAIL_CHARS = 4_000;
29
+
30
+ export const UPDATE_INSTALL_STATES = Object.freeze({
31
+ IDLE: 'idle',
32
+ RUNNING: 'running',
33
+ SUCCESS: 'success',
34
+ ERROR: 'error',
35
+ });
36
+
37
+ /**
38
+ * Boot-time reconciliation of a persisted update attempt. Pure; called before
39
+ * any service exists so even a crash loop settles the outcome exactly once.
40
+ */
41
+ /**
42
+ * Version equality on parsed triples so a `v`-prefixed manifest version still
43
+ * matches the package.json it installed; raw equality only when parsing fails
44
+ * (compareSemverIsh treats unparseable input as equal, which must not read as
45
+ * a successful update).
46
+ */
47
+ function sameVersion(a, b) {
48
+ if (parseSemverIsh(a) && parseSemverIsh(b)) return compareSemverIsh(a, b) === 0;
49
+ return String(a) === String(b);
50
+ }
51
+
52
+ export function reconcileUpdateAttempt({
53
+ readSetting,
54
+ writeSetting,
55
+ deleteSetting,
56
+ runningVersion,
57
+ nowMs = Date.now(),
58
+ // Generous on purpose: the restart is queue-idle-deferred and can sit for
59
+ // hours, and a swallowed failure outcome is worse than a late one.
60
+ maxAgeMs = 7 * 24 * 3_600_000,
61
+ } = {}) {
62
+ let attempt = null;
63
+ try {
64
+ attempt = JSON.parse(String(readSetting(UPDATE_ATTEMPT_SETTING_KEY) || ''));
65
+ } catch {
66
+ attempt = null;
67
+ }
68
+ if (!attempt || typeof attempt !== 'object') return null;
69
+ deleteSetting(UPDATE_ATTEMPT_SETTING_KEY);
70
+
71
+ const attempted = String(attempt.attemptedVersion || '');
72
+ const startedAtMs = Date.parse(String(attempt.startedAt || '')) || 0;
73
+ if (attempted && sameVersion(attempted, runningVersion)) {
74
+ const outcome = { status: 'success', version: attempted, at: new Date(nowMs).toISOString() };
75
+ writeSetting(UPDATE_OUTCOME_SETTING_KEY, JSON.stringify(outcome));
76
+ return outcome;
77
+ }
78
+ if (!startedAtMs || nowMs - startedAtMs > maxAgeMs) return null; // stale leftovers: drop quietly
79
+ const outcome = {
80
+ status: 'failure',
81
+ attemptedVersion: attempted,
82
+ fromVersion: String(attempt.fromVersion || ''),
83
+ logTail: String(attempt.logTail || ''),
84
+ at: new Date(nowMs).toISOString(),
85
+ };
86
+ writeSetting(UPDATE_OUTCOME_SETTING_KEY, JSON.stringify(outcome));
87
+ return outcome;
88
+ }
89
+
90
+ export function readUpdateOutcome(readSetting) {
91
+ try {
92
+ const parsed = JSON.parse(String(readSetting(UPDATE_OUTCOME_SETTING_KEY) || ''));
93
+ return parsed && typeof parsed === 'object' ? parsed : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ export function createUpdateInstallService({
100
+ runningVersion,
101
+ installMethod = 'npm-global',
102
+ spawnImpl = defaultSpawn,
103
+ env = process.env,
104
+ platform = process.platform,
105
+ setTimeoutImpl = setTimeout,
106
+ clearTimeoutImpl = clearTimeout,
107
+ nowImpl = Date.now,
108
+ readSetting,
109
+ writeSetting,
110
+ deleteSetting,
111
+ requestRelayShutdown,
112
+ logger = console,
113
+ } = {}) {
114
+ const listeners = new Set();
115
+ let state = UPDATE_INSTALL_STATES.IDLE;
116
+ let targetVersion = null;
117
+ let log = '';
118
+ let logSeq = 0;
119
+ let startedAt = null;
120
+ let finishedAt = null;
121
+ let lastError = null;
122
+ let flushTimer = null;
123
+ let pendingLog = '';
124
+ let activeChild = null;
125
+
126
+ function getSnapshot() {
127
+ return {
128
+ state,
129
+ targetVersion,
130
+ log,
131
+ logSeq,
132
+ startedAt,
133
+ finishedAt,
134
+ error: lastError,
135
+ active: state === UPDATE_INSTALL_STATES.RUNNING,
136
+ lastOutcome: readUpdateOutcome(readSetting),
137
+ };
138
+ }
139
+
140
+ function broadcast() {
141
+ const snapshot = getSnapshot();
142
+ for (const listener of listeners) {
143
+ try { listener(snapshot); } catch {}
144
+ }
145
+ }
146
+
147
+ function appendLog(chunk) {
148
+ pendingLog += chunk;
149
+ if (flushTimer) return;
150
+ flushTimer = setTimeoutImpl(() => {
151
+ flushTimer = null;
152
+ if (!pendingLog) return;
153
+ log = stripTerminalEscapes(`${log}${pendingLog}`).slice(-MAX_CAPTURED_OUTPUT_CHARS);
154
+ pendingLog = '';
155
+ logSeq += 1;
156
+ broadcast();
157
+ }, LOG_FLUSH_MS);
158
+ flushTimer?.unref?.();
159
+ }
160
+
161
+ function flushLogNow() {
162
+ if (flushTimer) {
163
+ clearTimeoutImpl(flushTimer);
164
+ flushTimer = null;
165
+ }
166
+ if (pendingLog) {
167
+ log = stripTerminalEscapes(`${log}${pendingLog}`).slice(-MAX_CAPTURED_OUTPUT_CHARS);
168
+ pendingLog = '';
169
+ logSeq += 1;
170
+ }
171
+ }
172
+
173
+ async function startUpdate({ version } = {}) {
174
+ if (state === UPDATE_INSTALL_STATES.RUNNING) {
175
+ return { ok: false, statusCode: 409, error: 'An update is already running' };
176
+ }
177
+ const target = String(version || '').trim();
178
+ if (!parseSemverIsh(target)) {
179
+ return { ok: false, statusCode: 400, error: 'version must be a release version like 0.9.2' };
180
+ }
181
+ if (installMethod !== 'npm-global') {
182
+ return { ok: false, statusCode: 400, error: 'This relay runs from a git checkout — pull to update' };
183
+ }
184
+ if (String(env?.COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN || '') === '1') {
185
+ return { ok: false, statusCode: 503, error: CLI_SPAWN_DISABLED_ERROR };
186
+ }
187
+
188
+ state = UPDATE_INSTALL_STATES.RUNNING;
189
+ targetVersion = target;
190
+ log = '';
191
+ // A timed-out child from a previous run can still be emitting: drop any
192
+ // buffered chunks and the armed flush so they can't bleed into this log.
193
+ pendingLog = '';
194
+ if (flushTimer) {
195
+ clearTimeoutImpl(flushTimer);
196
+ flushTimer = null;
197
+ }
198
+ logSeq += 1;
199
+ startedAt = new Date(nowImpl()).toISOString();
200
+ finishedAt = null;
201
+ lastError = null;
202
+ deleteSetting(UPDATE_OUTCOME_SETTING_KEY);
203
+ broadcast();
204
+
205
+ // npm itself is a shim on Windows; spawn npm.cmd through a shell there
206
+ // (args are fixed strings + a validated semver, so no injection surface).
207
+ const command = platform === 'win32' ? 'npm.cmd' : 'npm';
208
+ const args = ['install', '-g', `${UPDATE_PACKAGE_NAME}@${target}`];
209
+ const result = await runToCompletion(
210
+ () => spawnImpl(command, args, {
211
+ stdio: ['ignore', 'pipe', 'pipe'],
212
+ env: { ...env },
213
+ ...(platform === 'win32' ? { windowsHide: true, shell: true } : { detached: true }),
214
+ }),
215
+ {
216
+ timeoutMs: UPDATE_INSTALL_TIMEOUT_MS,
217
+ setTimeoutImpl,
218
+ clearTimeoutImpl,
219
+ killChild: (child, signal) => killTree(child, signal, { platform }),
220
+ onChild: (child) => { activeChild = child; },
221
+ onOutput: (text) => appendLog(text),
222
+ },
223
+ );
224
+ activeChild = null;
225
+ flushLogNow();
226
+ finishedAt = new Date(nowImpl()).toISOString();
227
+
228
+ if (!result.ok) {
229
+ state = UPDATE_INSTALL_STATES.ERROR;
230
+ lastError = result.error || `npm exited with code ${result.code}`;
231
+ broadcast();
232
+ return { ok: false, statusCode: 500, error: lastError };
233
+ }
234
+
235
+ // Persist the attempt BEFORE the restart request: the next boot compares
236
+ // the running version against it and turns "nothing changed" into a
237
+ // visible failure carrying this log tail.
238
+ writeSetting(UPDATE_ATTEMPT_SETTING_KEY, JSON.stringify({
239
+ attemptedVersion: target,
240
+ fromVersion: String(runningVersion),
241
+ startedAt,
242
+ logTail: tailOf(log, ATTEMPT_LOG_TAIL_CHARS),
243
+ }));
244
+ state = UPDATE_INSTALL_STATES.SUCCESS;
245
+ broadcast();
246
+ try {
247
+ requestRelayShutdown({ reason: 'self-update', requestedBy: 'update-install-service', restart: true });
248
+ } catch (error) {
249
+ logger.warn?.(`update install: restart request failed: ${error?.message || error}`);
250
+ }
251
+ return { ok: true, targetVersion: target };
252
+ }
253
+
254
+ function cancel() {
255
+ if (state === UPDATE_INSTALL_STATES.RUNNING && activeChild) {
256
+ try { killTree(activeChild, 'SIGTERM', { platform }); } catch {}
257
+ return { ok: true, cancelling: true };
258
+ }
259
+ // Terminal states reset to idle, mirroring cli-install-service's cancel.
260
+ state = UPDATE_INSTALL_STATES.IDLE;
261
+ targetVersion = null;
262
+ lastError = null;
263
+ broadcast();
264
+ return { ok: true };
265
+ }
266
+
267
+ function clearOutcome() {
268
+ deleteSetting(UPDATE_OUTCOME_SETTING_KEY);
269
+ broadcast();
270
+ return { ok: true };
271
+ }
272
+
273
+ function subscribe(listener) {
274
+ listeners.add(listener);
275
+ return () => listeners.delete(listener);
276
+ }
277
+
278
+ return { startUpdate, cancel, clearOutcome, getSnapshot, subscribe };
279
+ }
@@ -1,10 +1,15 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { randomUUID } from 'crypto';
4
+ import { resolveOarRoot } from './oar-state-migration-service.mjs';
4
5
 
5
- export const WINDOWS_AUTOSTART_FILENAME = 'copilot-remote-web-relay.cmd';
6
+ export const WINDOWS_AUTOSTART_FILENAME = 'oar-web-relay.cmd';
7
+ // The pre-rebrand name. Never written anymore, but still recognized (an install
8
+ // that enabled autostart before the OAR rename has this file) and cleaned up on
9
+ // every state change so the two names can never both launch a relay.
10
+ export const LEGACY_WINDOWS_AUTOSTART_FILENAME = 'copilot-remote-web-relay.cmd';
6
11
 
7
- function escapeBatchValue(value) {
12
+ export function escapeBatchValue(value) {
8
13
  return String(value || '').replaceAll('%', '%%');
9
14
  }
10
15
 
@@ -20,6 +25,10 @@ export function buildWindowsAutostartScript({
20
25
  packageRoot,
21
26
  nodePath = process.execPath,
22
27
  configPath = '',
28
+ // On Windows a process pins its cwd, and self-update must be able to
29
+ // replace the package directory — so the relay runs from the OAR state
30
+ // root, not from the code it executes.
31
+ workingDirectory = '',
23
32
  pathImpl = path,
24
33
  } = {}) {
25
34
  const resolvedPackageRoot = pathImpl.resolve(String(packageRoot || ''));
@@ -33,13 +42,16 @@ export function buildWindowsAutostartScript({
33
42
  const serverPath = pathImpl.join(resolvedPackageRoot, 'server', 'server.js');
34
43
  const lines = [
35
44
  '@echo off',
36
- 'title Copilot Remote Web Relay',
45
+ 'title OAR Web Relay',
37
46
  ];
38
47
  if (String(configPath || '').trim()) {
39
48
  lines.push(`set "COPILOT_WEB_RELAY_CONFIG=${escapeBatchValue(pathImpl.resolve(configPath))}"`);
40
49
  }
50
+ const cwdTarget = String(workingDirectory || '').trim()
51
+ ? pathImpl.resolve(String(workingDirectory))
52
+ : resolvedPackageRoot;
41
53
  lines.push(
42
- `cd /d "${escapeBatchValue(resolvedPackageRoot)}"`,
54
+ `cd /d "${escapeBatchValue(cwdTarget)}"`,
43
55
  `"${escapeBatchValue(resolvedNodePath)}" "${escapeBatchValue(serverPath)}"`,
44
56
  '',
45
57
  );
@@ -64,14 +76,33 @@ export function createWindowsAutostartService({
64
76
  );
65
77
  }
66
78
 
79
+ function legacyEntryPath() {
80
+ return pathImpl.join(
81
+ resolveWindowsStartupDirectory(env, pathImpl),
82
+ LEGACY_WINDOWS_AUTOSTART_FILENAME,
83
+ );
84
+ }
85
+
67
86
  function getState() {
87
+ // The legacy-named entry still counts as "enabled": it starts the relay at
88
+ // sign-in exactly like the current name does, and reporting it as off
89
+ // would invite enabling a second copy next to it.
90
+ const enabled = supported
91
+ && (fsImpl.existsSync(entryPath()) || fsImpl.existsSync(legacyEntryPath()));
68
92
  return {
69
93
  supported,
70
- enabled: supported ? fsImpl.existsSync(entryPath()) : false,
94
+ enabled: supported ? enabled : false,
71
95
  platform,
72
96
  };
73
97
  }
74
98
 
99
+ function removeLegacyEntry() {
100
+ const target = legacyEntryPath();
101
+ if (!fsImpl.existsSync(target)) return false;
102
+ fsImpl.unlinkSync(target);
103
+ return true;
104
+ }
105
+
75
106
  function setEnabled(enabled) {
76
107
  if (!supported) {
77
108
  throw new Error('Windows autostart is only available on Windows.');
@@ -83,14 +114,19 @@ export function createWindowsAutostartService({
83
114
  const targetPath = entryPath();
84
115
  const existed = fsImpl.existsSync(targetPath);
85
116
  if (!enabled) {
117
+ const removedLegacy = removeLegacyEntry();
86
118
  if (existed) fsImpl.unlinkSync(targetPath);
87
- return { ...getState(), changed: existed };
119
+ return { ...getState(), changed: existed || removedLegacy };
88
120
  }
121
+ // Enabling always converges on the one current filename; if only the
122
+ // legacy entry existed, the current-name write below reports the change.
123
+ removeLegacyEntry();
89
124
 
90
125
  const script = buildWindowsAutostartScript({
91
126
  packageRoot,
92
127
  nodePath,
93
128
  configPath,
129
+ workingDirectory: resolveOarRoot(env, platform),
94
130
  pathImpl,
95
131
  });
96
132
  if (existed && fsImpl.readFileSync(targetPath, 'utf8') === script) {