@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
@@ -29,7 +29,11 @@ import {
29
29
  updateTurnCeilingSetting as requestTurnCeilingSetting,
30
30
  loadBackgroundTaskTimeoutSetting,
31
31
  updateBackgroundTaskTimeoutSetting as requestBackgroundTaskTimeoutSetting,
32
+ loadFeatureFlagSettings,
33
+ updateFeatureFlagSetting as requestFeatureFlagSetting,
34
+ requestRelayRestart,
32
35
  } from './api-client.js';
36
+ import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
33
37
  import { syncFontScaleSelect } from './font-scaling.js';
34
38
  import { syncPwaAppNameInput } from './pwa-install.js';
35
39
  import { normalizeKnownCwdPath } from './cwd-picker.js';
@@ -38,6 +42,7 @@ import { selectSettingsTab } from './settings-tabs.js';
38
42
  import { refreshClaudeAuthSection } from './claude-auth-ui.js';
39
43
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
40
44
  import { refreshCliInstallSections } from './cli-install-ui.js';
45
+ import { refreshUpdateSection } from './update-ui.js';
41
46
  import {
42
47
  getPreviews,
43
48
  renderPreviewRowsInto,
@@ -69,7 +74,8 @@ function ensureOpenAISettingsInputTracking() {
69
74
  }
70
75
  }
71
76
  let windowsAutostartUpdateInFlight = false;
72
- let windowsAutostartEnabled = false;
77
+ let windowsAutostartState = null;
78
+ let windowsAutostartPollTimer = null;
73
79
 
74
80
  // Mirrors shared/turn-ceiling.mjs, which the browser cannot import — only
75
81
  // server/public is served. The authoritative bounds come from the API response;
@@ -147,9 +153,10 @@ export async function updateTurnCeilingSetting(value) {
147
153
  }
148
154
  }
149
155
 
150
- // Mirrors shared/background-task-timeout.mjs (the browser cannot import it).
151
- // Bounds come from the API response; these are just pre-response defaults.
152
- let backgroundTaskTimeoutMinutes = 0;
156
+ // Starts at the default the relay enforces, so the slider tells the truth while
157
+ // the settings request is in flight or after it failed. Bounds come from the
158
+ // API response.
159
+ let backgroundTaskTimeoutMinutes = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES;
153
160
  let backgroundTaskTimeoutUpdateInFlight = false;
154
161
 
155
162
  function syncBackgroundTaskTimeoutSlider() {
@@ -348,8 +355,8 @@ export function applyCopilotSettingsState(settings = {}, { error = '' } = {}) {
348
355
  status.dataset.state = 'error';
349
356
  } else {
350
357
  status.textContent = copilotSettingsState.engine === 'sdk'
351
- ? 'New Copilot conversations run on the experimental headless SDK worker. No tmux inspector for these sessions.'
352
- : 'New Copilot conversations run on the Copilot CLI with the web-relay extension (current default).';
358
+ ? 'New Copilot conversations run on the headless SDK worker. No tmux inspector for these sessions.'
359
+ : 'New Copilot conversations run on the Copilot CLI with the web-relay extension.';
353
360
  status.dataset.state = copilotSettingsState.engine === 'sdk' ? 'active' : 'unconfigured';
354
361
  }
355
362
  }
@@ -391,7 +398,7 @@ export async function saveCopilotSettings() {
391
398
  settled = result;
392
399
  showTransientRelayNotice(
393
400
  String(result.engine) === 'sdk'
394
- ? 'Copilot engine set to SDK (experimental). New conversations use the headless worker; running sessions keep the extension until they restart.'
401
+ ? 'Copilot engine set to SDK. New conversations use the headless worker; running sessions keep their engine until they restart.'
395
402
  : 'Copilot engine set to Extension. New conversations use the Copilot CLI.',
396
403
  6000,
397
404
  );
@@ -1250,15 +1257,79 @@ export async function removeOpenAISettings() {
1250
1257
  }
1251
1258
  }
1252
1259
 
1260
+ function windowsAutostartStatusText() {
1261
+ const boot = windowsAutostartState?.boot;
1262
+ if (boot?.pendingElevation) {
1263
+ return 'Confirm the admin prompt on the PC… (it only appears on the physical desktop)';
1264
+ }
1265
+ if (boot?.lastError) return boot.lastError;
1266
+ if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
1267
+ return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
1268
+ }
1269
+ if (boot?.taskStatus === 'foreign') {
1270
+ return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
1271
+ }
1272
+ if (boot?.legacyTaskPresent && windowsAutostartState?.mode !== 'boot') {
1273
+ return 'A pre-rebrand boot task exists. Selecting "At system startup" migrates it (one admin confirmation).';
1274
+ }
1275
+ return '';
1276
+ }
1277
+
1253
1278
  function syncWindowsAutostartSetting() {
1254
1279
  const container = document.getElementById('windows-autostart-setting');
1255
- const checkbox = document.getElementById('windows-autostart-toggle');
1256
1280
  const supported = serverPlatform === 'win32';
1257
1281
  if (container) container.hidden = !supported;
1258
- if (checkbox instanceof HTMLInputElement) {
1259
- checkbox.checked = windowsAutostartEnabled;
1260
- checkbox.disabled = !supported || windowsAutostartUpdateInFlight;
1282
+ const mode = windowsAutostartState?.mode || 'off';
1283
+ const pending = !!windowsAutostartState?.boot?.pendingElevation;
1284
+ for (const radio of document.querySelectorAll('input[name="windows-autostart-mode"]')) {
1285
+ radio.checked = radio.value === mode;
1286
+ radio.disabled = !supported || windowsAutostartUpdateInFlight || pending;
1261
1287
  }
1288
+ const status = document.getElementById('windows-autostart-status');
1289
+ if (status) {
1290
+ const text = windowsAutostartStatusText();
1291
+ status.hidden = !text;
1292
+ status.textContent = text;
1293
+ }
1294
+ const manual = document.getElementById('windows-autostart-manual');
1295
+ const manualCommand = document.getElementById('windows-autostart-manual-command');
1296
+ const showManual = !!windowsAutostartState?.boot?.lastError && !!windowsAutostartState?.boot?.manualCommand;
1297
+ if (manual) manual.hidden = !showManual;
1298
+ if (manualCommand && showManual) manualCommand.textContent = windowsAutostartState.boot.manualCommand;
1299
+ }
1300
+
1301
+ function stopWindowsAutostartPoll() {
1302
+ if (windowsAutostartPollTimer) {
1303
+ clearTimeout(windowsAutostartPollTimer);
1304
+ windowsAutostartPollTimer = null;
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * The UAC prompt renders on the PC's own desktop, so a remote browser can
1310
+ * only wait: poll until the elevation resolves either way, bounded to the
1311
+ * service's own 120s timeout.
1312
+ */
1313
+ function pollWindowsAutostartWhilePending(deadline) {
1314
+ stopWindowsAutostartPoll();
1315
+ if (!windowsAutostartState?.boot?.pendingElevation || Date.now() > deadline) return;
1316
+ windowsAutostartPollTimer = setTimeout(async () => {
1317
+ try {
1318
+ windowsAutostartState = await loadWindowsAutostartSetting();
1319
+ } catch {
1320
+ // transient; the next tick retries
1321
+ }
1322
+ syncWindowsAutostartSetting();
1323
+ if (windowsAutostartState?.boot?.pendingElevation) {
1324
+ pollWindowsAutostartWhilePending(deadline);
1325
+ } else if (!windowsAutostartState?.boot?.lastError) {
1326
+ showTransientRelayNotice(
1327
+ windowsAutostartState?.mode === 'boot'
1328
+ ? 'System-startup autostart enabled.'
1329
+ : 'Boot task removed.',
1330
+ );
1331
+ }
1332
+ }, 2_000);
1262
1333
  }
1263
1334
 
1264
1335
  export async function refreshWindowsAutostartSetting() {
@@ -1272,16 +1343,19 @@ export async function refreshWindowsAutostartSetting() {
1272
1343
  alert('Failed to read the Windows autostart setting.');
1273
1344
  return;
1274
1345
  }
1275
- windowsAutostartEnabled = !!result.enabled;
1346
+ windowsAutostartState = result;
1276
1347
  } catch (error) {
1277
1348
  alert(error?.message || 'Failed to read the Windows autostart setting.');
1278
1349
  } finally {
1279
1350
  windowsAutostartUpdateInFlight = false;
1280
1351
  syncWindowsAutostartSetting();
1352
+ if (windowsAutostartState?.boot?.pendingElevation) {
1353
+ pollWindowsAutostartWhilePending(Date.now() + 120_000);
1354
+ }
1281
1355
  }
1282
1356
  }
1283
1357
 
1284
- export async function updateWindowsAutostartSettingFromToggle(enabled) {
1358
+ export async function updateWindowsAutostartMode(mode) {
1285
1359
  if (serverPlatform !== 'win32' || windowsAutostartUpdateInFlight) {
1286
1360
  syncWindowsAutostartSetting();
1287
1361
  return;
@@ -1289,17 +1363,21 @@ export async function updateWindowsAutostartSettingFromToggle(enabled) {
1289
1363
  windowsAutostartUpdateInFlight = true;
1290
1364
  syncWindowsAutostartSetting();
1291
1365
  try {
1292
- const result = await updateWindowsAutostartSetting(!!enabled);
1366
+ const result = await updateWindowsAutostartSetting(String(mode));
1293
1367
  if (!result?.supported) {
1294
1368
  alert('Failed to update the Windows autostart setting.');
1295
1369
  return;
1296
1370
  }
1297
- windowsAutostartEnabled = !!result.enabled;
1298
- showTransientRelayNotice(
1299
- windowsAutostartEnabled
1300
- ? 'Windows autostart enabled.'
1301
- : 'Windows autostart disabled.',
1302
- );
1371
+ windowsAutostartState = result;
1372
+ if (result?.boot?.pendingElevation) {
1373
+ pollWindowsAutostartWhilePending(Date.now() + 120_000);
1374
+ } else if (!result?.boot?.lastError) {
1375
+ showTransientRelayNotice(
1376
+ result.mode === 'signin' ? 'Sign-in autostart enabled.'
1377
+ : result.mode === 'off' ? 'Windows autostart disabled.'
1378
+ : 'Windows autostart updated.',
1379
+ );
1380
+ }
1303
1381
  } catch (error) {
1304
1382
  alert(error?.message || 'Failed to update the Windows autostart setting.');
1305
1383
  } finally {
@@ -1308,6 +1386,131 @@ export async function updateWindowsAutostartSettingFromToggle(enabled) {
1308
1386
  }
1309
1387
  }
1310
1388
 
1389
+ // ─── Features tab ─────────────────────────────────────────────────────────────
1390
+ // Flags are resolved once at relay boot; the rows here render entirely from the
1391
+ // server's registry payload (labels and descriptions live in server/features.mjs),
1392
+ // and the restart notice appears whenever a stored value differs from the
1393
+ // running snapshot. Rows are dynamic, so listeners are attached during render
1394
+ // instead of via inline onchange globals.
1395
+
1396
+ let featureFlagsState = null;
1397
+ let featureFlagsUpdateInFlight = false;
1398
+ let featuresRestartButtonBound = false;
1399
+
1400
+ function featureToggleId(name) {
1401
+ return `feature-toggle-${String(name || '').toLowerCase()}`;
1402
+ }
1403
+
1404
+ function syncFeatureFlagsSection() {
1405
+ const list = document.getElementById('features-flag-list');
1406
+ if (!list) return;
1407
+ list.textContent = '';
1408
+ const flags = Array.isArray(featureFlagsState?.flags) ? featureFlagsState.flags : [];
1409
+ for (const flag of flags) {
1410
+ const row = document.createElement('div');
1411
+ row.className = 'settings-row';
1412
+ row.style.marginTop = '12px';
1413
+ row.style.marginBottom = '0';
1414
+
1415
+ const label = document.createElement('label');
1416
+ label.htmlFor = featureToggleId(flag.name);
1417
+ label.textContent = flag.label || flag.name;
1418
+ row.appendChild(label);
1419
+
1420
+ const checkbox = document.createElement('input');
1421
+ checkbox.id = featureToggleId(flag.name);
1422
+ checkbox.type = 'checkbox';
1423
+ checkbox.checked = flag.effectiveNext === true;
1424
+ checkbox.disabled = featureFlagsUpdateInFlight || flag.envOverride !== null;
1425
+ checkbox.setAttribute('aria-label', flag.label || flag.name);
1426
+ checkbox.addEventListener('change', () => {
1427
+ void updateFeatureFlagFromToggle(flag.name, checkbox.checked);
1428
+ });
1429
+ row.appendChild(checkbox);
1430
+ list.appendChild(row);
1431
+
1432
+ const help = document.createElement('div');
1433
+ help.className = 'settings-help';
1434
+ help.style.marginTop = '6px';
1435
+ let text = String(flag.description || '');
1436
+ if (flag.envOverride !== null) {
1437
+ text += ` Pinned ${flag.envOverride ? 'on' : 'off'} by COPILOT_REMOTE_${flag.name} in the relay's environment; the toggle is locked while that variable is set.`;
1438
+ }
1439
+ if (flag.restartRequired) {
1440
+ text += ' Restart pending: the saved value takes effect after the relay restarts.';
1441
+ }
1442
+ help.textContent = text;
1443
+ list.appendChild(help);
1444
+ }
1445
+
1446
+ const notice = document.getElementById('features-restart-notice');
1447
+ if (notice) notice.hidden = featureFlagsState?.restartRequired !== true;
1448
+ const restartBtn = document.getElementById('features-restart-btn');
1449
+ if (restartBtn) {
1450
+ restartBtn.disabled = featureFlagsUpdateInFlight;
1451
+ if (!featuresRestartButtonBound) {
1452
+ featuresRestartButtonBound = true;
1453
+ restartBtn.addEventListener('click', () => { void restartRelayForFeatureFlags(); });
1454
+ }
1455
+ }
1456
+ }
1457
+
1458
+ function setFeaturesSettingsStatus(text) {
1459
+ const status = document.getElementById('features-settings-status');
1460
+ if (!status) return;
1461
+ status.hidden = !text;
1462
+ status.textContent = text || '';
1463
+ }
1464
+
1465
+ export async function refreshFeatureFlagsSection() {
1466
+ syncFeatureFlagsSection();
1467
+ const result = await loadFeatureFlagSettings();
1468
+ if (!result || !Array.isArray(result.flags)) {
1469
+ setFeaturesSettingsStatus('Failed to load feature flags from the relay.');
1470
+ return;
1471
+ }
1472
+ featureFlagsState = result;
1473
+ setFeaturesSettingsStatus('');
1474
+ syncFeatureFlagsSection();
1475
+ }
1476
+
1477
+ async function updateFeatureFlagFromToggle(name, enabled) {
1478
+ if (featureFlagsUpdateInFlight) {
1479
+ syncFeatureFlagsSection();
1480
+ return;
1481
+ }
1482
+ featureFlagsUpdateInFlight = true;
1483
+ syncFeatureFlagsSection();
1484
+ try {
1485
+ const result = await requestFeatureFlagSetting(name, enabled);
1486
+ if (!result || !Array.isArray(result.flags)) {
1487
+ alert('Failed to update the feature flag.');
1488
+ return;
1489
+ }
1490
+ featureFlagsState = result;
1491
+ } finally {
1492
+ featureFlagsUpdateInFlight = false;
1493
+ syncFeatureFlagsSection();
1494
+ }
1495
+ }
1496
+
1497
+ async function restartRelayForFeatureFlags() {
1498
+ if (featureFlagsUpdateInFlight) return;
1499
+ featureFlagsUpdateInFlight = true;
1500
+ syncFeatureFlagsSection();
1501
+ try {
1502
+ const result = await requestRelayRestart({ reason: 'feature-flags-changed' });
1503
+ if (!result) {
1504
+ alert('Failed to request a relay restart. Restarts can only be requested from the relay host itself.');
1505
+ return;
1506
+ }
1507
+ showTransientRelayNotice('Relay restart requested. It will restart once the queue is idle.');
1508
+ } finally {
1509
+ featureFlagsUpdateInFlight = false;
1510
+ syncFeatureFlagsSection();
1511
+ }
1512
+ }
1513
+
1311
1514
  function readShowSuspendHostSetting() {
1312
1515
  const stored = String(readLocalStorage(SHOW_SUSPEND_HOST_STORAGE_KEY) || '').trim().toLowerCase();
1313
1516
  if (!stored) return true;
@@ -1473,6 +1676,8 @@ export function openSettingsModal(tab, providerTab) {
1473
1676
  void refreshBackgroundTaskTimeoutSetting();
1474
1677
  void refreshPushSettingsSection();
1475
1678
  void refreshPreviewsSection();
1679
+ void refreshFeatureFlagsSection();
1680
+ void refreshUpdateSection();
1476
1681
  modal?.classList.add('visible');
1477
1682
  modal?.setAttribute('aria-hidden', 'false');
1478
1683
  }
@@ -1,7 +1,7 @@
1
1
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
2
2
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
3
3
 
4
- const SETTINGS_TABS = ['general', 'providers', 'previews', 'notifications'];
4
+ const SETTINGS_TABS = ['general', 'providers', 'previews', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
7
  const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'grok', 'cursor'];
@@ -28,6 +28,8 @@ import {
28
28
  addSubagentThought,
29
29
  clearSubagentCancelInFlight,
30
30
  setConversationWatcherCount,
31
+ setUpdateState,
32
+ applySessionWorkerSteering,
31
33
  } from './store.js';
32
34
  import { scheduleContextUsageRefresh } from './api-client.js';
33
35
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
@@ -43,6 +45,7 @@ import { setPreviews } from './preview-cards.mjs';
43
45
  import { applyClaudeAuthState } from './claude-auth-ui.js';
44
46
  import { applyGrokAuthState } from './grok-auth-ui.js';
45
47
  import { applyCliInstallState } from './cli-install-ui.js';
48
+ import { renderUpdateSection } from './update-ui.js';
46
49
  import {
47
50
  showThinking,
48
51
  removeThinking,
@@ -53,6 +56,7 @@ import {
53
56
  appendThinkingActivity,
54
57
  appendThinkingThought,
55
58
  applyRelayStreamEvent,
59
+ clearRelayStreamState,
56
60
  clearRelayStreamStateForMessage,
57
61
  applyConversationTurnStatus,
58
62
  renderMessages,
@@ -63,6 +67,8 @@ import {
63
67
  removeUserBubbleCancelButton,
64
68
  updateSubagentBubbleFromStatus,
65
69
  markSubagentStopUnsupported,
70
+ syncComposerButtonState,
71
+ syncCancellableSteerButtons,
66
72
  } from './conversation-view.js';
67
73
  import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
68
74
  import { clearMessageSearchRuntimeState } from './message-search-view.js';
@@ -376,6 +382,12 @@ export async function connectSocket(overrideDeps) {
376
382
  socket.on('cli_install_state', (payload) => {
377
383
  applyCliInstallState(payload || null);
378
384
  });
385
+ // Self-update transitions AND npm log chunks; same whole-buffer contract as
386
+ // cli_install_state, so a client connecting mid-update renders the full log.
387
+ socket.on('update_state', (payload) => {
388
+ setUpdateState(payload || null);
389
+ renderUpdateSection();
390
+ });
379
391
  socket.on('grok_settings_updated', (payload) => {
380
392
  deps?.applyGrokSettingsState?.(payload || {});
381
393
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
@@ -437,8 +449,12 @@ export async function connectSocket(overrideDeps) {
437
449
  socket.on('assistant_message', ({ conversationId, message, messageId, sourceMessageId }) => {
438
450
  const isCurrentConversation = conversationId === currentConvId;
439
451
  const autoScroll = isCurrentConversation ? isMessagesAtBottom() : false;
440
- collapseThinkingThoughts();
441
- removeThinking();
452
+ // A background conversation finishing its turn must not wipe the viewed
453
+ // conversation's live bubble.
454
+ if (isCurrentConversation) {
455
+ collapseThinkingThoughts();
456
+ removeThinking();
457
+ }
442
458
  if ((!message?.activities || !message.activities.length) && sourceMessageId) {
443
459
  const cached = relayActivities.get(sourceMessageId) || [];
444
460
  if (cached.length) message.activities = cached.slice(0, 48);
@@ -614,11 +630,27 @@ export async function connectSocket(overrideDeps) {
614
630
  runtimeSessionId: String(runtimeSessionId || conversations[id].runtimeSessionId || '').trim() || null,
615
631
  };
616
632
  }
633
+ // The composer reads the worker's steering snapshot under the session id
634
+ // just bound; fetch it now rather than on the next status poll.
635
+ refreshSessionWorkerStatus().catch(() => {});
617
636
  await refreshConversations();
618
637
  if (currentConvId === id) {
619
638
  await openConversation(id);
620
639
  }
621
640
  });
641
+ // A worker's steering snapshot changed (its heartbeat): the composer's
642
+ // Steer/Queue label and the pushed rows' Cancel follow it at once instead of
643
+ // on the next status poll.
644
+ socket.on('session_worker_steering', ({ sdkSessionId, steering } = {}) => {
645
+ const changed = applySessionWorkerSteering(sdkSessionId, steering);
646
+ if (changed === null) {
647
+ refreshSessionWorkerStatus().catch(() => {});
648
+ return;
649
+ }
650
+ if (!changed) return;
651
+ syncComposerButtonState();
652
+ syncCancellableSteerButtons();
653
+ });
622
654
  socket.on('message_status', ({ messageId, conversationId, status }) => {
623
655
  const normalizedStatus = String(status || '').trim().toLowerCase();
624
656
  const clearsProcessingStatus = ['done', 'failed', 'dropped', 'pending', 'parked', 'cancelled'].includes(normalizedStatus);
@@ -650,9 +682,20 @@ export async function connectSocket(overrideDeps) {
650
682
  if (messageId) renderRestoredSubagentBubbles(messageId);
651
683
  if (messageId) removeUserBubbleCancelButton(messageId);
652
684
  }
653
- if (clearsProcessingStatus) {
685
+ if (normalizedStatus === 'processing') {
686
+ // A worker owns the message now: dropping the pending entry re-arms the
687
+ // duplicate-send guard and hands poll liveness over to localTurnStatus.
654
688
  clearPendingUserMessage(messageId);
655
- if (messageId) clearRelayStreamStateForMessage(messageId);
689
+ }
690
+ if (clearsProcessingStatus) {
691
+ // 'pending'/'parked' mean the message is (still) queued: its pending
692
+ // entry keeps the live poll armed while it waits, and only a genuinely
693
+ // terminal status may blacklist the id from relay_stream frames — the
694
+ // server acks every enqueue with 'pending', which would otherwise mute
695
+ // live streaming for the whole turn.
696
+ if (isTerminalStatus) clearPendingUserMessage(messageId);
697
+ if (messageId && isTerminalStatus) clearRelayStreamStateForMessage(messageId);
698
+ else if (messageId) clearRelayStreamState(messageId);
656
699
  if (messageId) clearBubbleCancelState(messageId);
657
700
  if (messageId) removeUserBubbleCancelButton(messageId);
658
701
  }
@@ -0,0 +1,100 @@
1
+ // Mid-turn steering: a message sent while a Claude turn was running is pushed
2
+ // into the live turn. Three settle shapes reach the transcript:
3
+ // - handoff: the interrupted reply settles with kind='absorbed', and the rest
4
+ // of the turn (with the final reply) lands on the steered message's own row.
5
+ // Those rows render as ONE flow — absorbed reply, steered user bubble,
6
+ // continuing reply.
7
+ // - fold: the CLI answered the steer inside the running turn; its row settles
8
+ // with a kind='folded' stub, a compact marker under its own user message.
9
+ // It never merges forward — the next message is an ordinary turn.
10
+ // - stopped: the steer was pushed into a turn the user then stopped, so it
11
+ // went unanswered (kind='stopped', a marker with Resend).
12
+ // The steered user message of a fold or stop is still classed as steered.
13
+ //
14
+ // One idempotent DOM pass, same contract as syncTranscriptSeparators: every
15
+ // insertion path (full render, live append, prepended history page) re-runs it,
16
+ // so classes heal when the pair arrives across two loads and un-apply when a
17
+ // re-render drops the absorbed marker. Kept DOM-only and import-light so it is
18
+ // jsdom-testable (conversation-view.js touches window at module scope).
19
+ import { SEPARATOR_CLASS } from './transcript-separators.mjs';
20
+
21
+ export const ABSORBED_MSG_CLASS = 'msg-absorbed';
22
+ export const STEERED_MSG_CLASS = 'msg-steered';
23
+ export const STEERED_CONTINUATION_CLASS = 'msg-steered-continuation';
24
+ export const FOLDED_MSG_CLASS = 'msg-folded';
25
+ export const STEER_STOPPED_MSG_CLASS = 'msg-steer-stopped';
26
+
27
+ /** The assistant row kinds that are settle markers of a steer, not replies. */
28
+ export const STEER_MARKER_CLASS_BY_KIND = Object.freeze({
29
+ folded: FOLDED_MSG_CLASS,
30
+ stopped: STEER_STOPPED_MSG_CLASS,
31
+ });
32
+
33
+ function isSteerMarker(node) {
34
+ return !!node?.classList
35
+ && node.classList.contains('assistant')
36
+ && (node.classList.contains(FOLDED_MSG_CLASS) || node.classList.contains(STEER_STOPPED_MSG_CLASS));
37
+ }
38
+
39
+ /**
40
+ * The `.msg` row above, refusing to cross a separator row: a steering pair is
41
+ * seconds apart, so a day or compaction break between two rows proves they are
42
+ * NOT that pair, and pulling them together across it would look broken.
43
+ */
44
+ function previousMessageNode(node) {
45
+ for (let prev = node?.previousElementSibling; prev; prev = prev.previousElementSibling) {
46
+ if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
47
+ if (prev.classList?.contains('msg')) return prev;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** The `.msg` row below, refusing to cross a separator row. */
53
+ function nextMessageNode(node) {
54
+ for (let next = node?.nextElementSibling; next; next = next.nextElementSibling) {
55
+ if (next.classList?.contains(SEPARATOR_CLASS)) return null;
56
+ if (next.classList?.contains('msg')) return next;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function setClass(node, className, on) {
62
+ if (!node?.classList) return 0;
63
+ if (node.classList.contains(className) === on) return 0;
64
+ node.classList.toggle(className, on);
65
+ return 1;
66
+ }
67
+
68
+ /**
69
+ * Sync the merge classes over the container's rows. Returns the number of
70
+ * class changes (0 = the pass was a no-op), which the tests assert on.
71
+ */
72
+ export function syncSteeredTurnMerge(container) {
73
+ if (!container || !container.children) return 0;
74
+ let changes = 0;
75
+ for (const node of container.children) {
76
+ if (!node.classList?.contains('msg')) continue;
77
+ if (node.classList.contains('user')) {
78
+ // A user row is steered iff the row above it is an absorbed reply, or
79
+ // its own settle marker (fold/stop) sits right below it.
80
+ const previous = previousMessageNode(node);
81
+ const steered = (!!previous
82
+ && previous.classList.contains('assistant')
83
+ && previous.classList.contains(ABSORBED_MSG_CLASS))
84
+ || isSteerMarker(nextMessageNode(node));
85
+ changes += setClass(node, STEERED_MSG_CLASS, steered);
86
+ continue;
87
+ }
88
+ if (node.classList.contains('assistant')) {
89
+ // The reply continuing a steered message keeps the group together. A
90
+ // settle marker is not a reply: it keeps its own compact styling.
91
+ const previous = previousMessageNode(node);
92
+ const continues = !isSteerMarker(node)
93
+ && !!previous
94
+ && previous.classList.contains('user')
95
+ && previous.classList.contains(STEERED_MSG_CLASS);
96
+ changes += setClass(node, STEERED_CONTINUATION_CLASS, continues);
97
+ }
98
+ }
99
+ return changes;
100
+ }