@oar-sh/oar 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +440 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +112 -25
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +217 -11
  8. package/server/claude-worker/claude-session-worker.mjs +26 -1
  9. package/server/claude-worker/task-output-discovery.mjs +126 -0
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +235 -14
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -0
  12. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  13. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  14. package/server/db-schema.mjs +68 -2
  15. package/server/grok-worker/acp-client.mjs +2 -1
  16. package/server/grok-worker/acp-host-services.mjs +46 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +241 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0013-previews.mjs +130 -0
  22. package/server/migrations/0014-conversation-groups.mjs +89 -0
  23. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  24. package/server/migrations/0016-share-access-log.mjs +110 -0
  25. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  30. package/server/public/app/api-client.js +264 -16
  31. package/server/public/app/ask-user-view.js +17 -15
  32. package/server/public/app/background-tasks-view.mjs +285 -16
  33. package/server/public/app/bootstrap.js +253 -37
  34. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  35. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  36. package/server/public/app/context-menu.mjs +90 -6
  37. package/server/public/app/conversation-group-drag.mjs +191 -0
  38. package/server/public/app/conversation-group-order.mjs +265 -0
  39. package/server/public/app/conversation-group-state.mjs +100 -0
  40. package/server/public/app/conversation-list-drag.mjs +449 -0
  41. package/server/public/app/conversation-payload-cache.mjs +357 -0
  42. package/server/public/app/conversation-turn-state.mjs +81 -0
  43. package/server/public/app/conversation-view.js +567 -94
  44. package/server/public/app/conversation-window-retention.mjs +96 -0
  45. package/server/public/app/cwd-picker.js +1 -1
  46. package/server/public/app/hidden-messages-view.mjs +312 -0
  47. package/server/public/app/host-suspend-ui.js +7 -1
  48. package/server/public/app/journal-view.js +1033 -82
  49. package/server/public/app/preview-cards.mjs +117 -31
  50. package/server/public/app/preview-command.mjs +10 -1
  51. package/server/public/app/relay-board-view.js +19 -6
  52. package/server/public/app/relay-prompt-sanitizer.mjs +11 -4
  53. package/server/public/app/relay-restart-error.mjs +16 -0
  54. package/server/public/app/settings-modal.js +150 -10
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/share-format.mjs +49 -0
  57. package/server/public/app/share-preview-marks.mjs +167 -0
  58. package/server/public/app/share-state.js +109 -0
  59. package/server/public/app/shared-conversations-settings.js +480 -0
  60. package/server/public/app/socket-handlers.js +138 -20
  61. package/server/public/app/store.js +121 -30
  62. package/server/public/app/subagent-row.mjs +3 -3
  63. package/server/public/app/subagent-viewer.mjs +37 -7
  64. package/server/public/app/task-console-render.mjs +237 -0
  65. package/server/public/app/task-console-watch.mjs +42 -0
  66. package/server/public/app/task-console.mjs +630 -0
  67. package/server/public/app/task-output-text.mjs +234 -0
  68. package/server/public/app/thinking-follow.js +0 -4
  69. package/server/public/app/transcript-island.mjs +73 -0
  70. package/server/public/app/transcript-jump-bottom-view.mjs +230 -26
  71. package/server/public/app/transcript-jump-bottom.mjs +170 -22
  72. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  73. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  74. package/server/public/app/transcript-viewport-anchor.mjs +159 -0
  75. package/server/public/index.html +625 -21
  76. package/server/relay-tools.md +3 -1
  77. package/server/relay.mjs +2 -1
  78. package/server/repositories/conversation-group-repository.mjs +444 -0
  79. package/server/repositories/message-repository.mjs +9 -0
  80. package/server/repositories/question-repository.mjs +4 -0
  81. package/server/repositories/session-repository.mjs +12 -37
  82. package/server/repositories/share-repository.mjs +322 -0
  83. package/server/repositories/share-visibility-statements.mjs +10 -0
  84. package/server/routes/about-routes.mjs +15 -0
  85. package/server/routes/cache-routes.mjs +4 -0
  86. package/server/routes/conversation-group-routes.mjs +171 -0
  87. package/server/routes/messages-routes.mjs +433 -68
  88. package/server/routes/preview-routes.mjs +9 -1
  89. package/server/routes/sessions-routes.mjs +901 -253
  90. package/server/server-runtime.mjs +392 -99
  91. package/server/services/about-service.mjs +461 -0
  92. package/server/services/background-task-store.mjs +365 -0
  93. package/server/services/background-work-view.mjs +228 -0
  94. package/server/services/cache-rebuild-service.mjs +30 -0
  95. package/server/services/cli-process-runner.mjs +4 -21
  96. package/server/services/conversation-history-page.mjs +199 -0
  97. package/server/services/delete-archive-service.mjs +4 -1
  98. package/server/services/detached-worker-watch-service.mjs +150 -0
  99. package/server/services/hidden-message-list.mjs +21 -0
  100. package/server/services/host-suspend-command.mjs +2 -1
  101. package/server/services/message-pin-list.mjs +1 -1
  102. package/server/services/preview-health-probe.mjs +8 -1
  103. package/server/services/preview-proxy-server.mjs +8 -4
  104. package/server/services/preview-registry-service.mjs +676 -26
  105. package/server/services/preview-static-handler.mjs +40 -7
  106. package/server/services/preview-store.mjs +274 -0
  107. package/server/services/rate-limit-service.mjs +12 -1
  108. package/server/services/relay-auth.mjs +183 -0
  109. package/server/services/relay-cli-launcher-service.mjs +3 -1
  110. package/server/services/relay-event-service.mjs +17 -0
  111. package/server/services/relay-prompt-sanitizer.mjs +11 -4
  112. package/server/services/remote-relay-dispatcher.mjs +187 -7
  113. package/server/services/request-boundary.mjs +183 -0
  114. package/server/services/session-activity-tracker.mjs +231 -19
  115. package/server/services/session-worker-launch-service.mjs +190 -6
  116. package/server/services/session-worker-process-service.mjs +10 -6
  117. package/server/services/share-access-log-service.mjs +123 -0
  118. package/server/services/share-path-redaction.mjs +424 -0
  119. package/server/services/status-event-service.mjs +7 -3
  120. package/server/services/task-output-rooms.mjs +259 -0
  121. package/server/services/task-output-service.mjs +562 -0
  122. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  123. package/server/services/windows-autostart-service.mjs +6 -1
  124. package/server/services/windows-boot-autostart-service.mjs +207 -23
  125. package/shared/claude-background-command-limit.mjs +83 -0
  126. package/shared/preview-tool-core.mjs +15 -5
  127. package/shared/remote-relay-contract.mjs +35 -3
  128. package/shared/share-path-privacy.mjs +30 -0
  129. package/shared/task-output-text.mjs +737 -0
  130. package/shared/windows-system-tools.mjs +106 -0
  131. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  132. package/shared/worker-runtime/heartbeat.mjs +24 -0
  133. package/shared/worker-runtime/kept-posts.mjs +83 -0
  134. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  135. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -29,6 +29,8 @@ import {
29
29
  updateTurnCeilingSetting as requestTurnCeilingSetting,
30
30
  loadBackgroundTaskTimeoutSetting,
31
31
  updateBackgroundTaskTimeoutSetting as requestBackgroundTaskTimeoutSetting,
32
+ loadTaskStallMinutesSetting,
33
+ updateTaskStallMinutesSetting as requestTaskStallMinutesSetting,
32
34
  loadSubagentRecordRetentionSetting,
33
35
  updateSubagentRecordRetentionSetting as requestSubagentRecordRetentionSetting,
34
36
  loadFeatureFlagSettings,
@@ -36,6 +38,13 @@ import {
36
38
  requestRelayRestart,
37
39
  } from './api-client.js';
38
40
  import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
41
+ import {
42
+ DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
43
+ formatClaudeBackgroundCommandLimitLabel,
44
+ } from './claude-background-command-limit-options.mjs';
45
+ import { DEFAULT_TASK_STALL_MINUTES } from './task-console-render.mjs';
46
+ import { setTaskStallThreshold } from './background-tasks-view.mjs';
47
+ import { describeRelayRestartError } from './relay-restart-error.mjs';
39
48
  import { syncFontScaleSelect } from './font-scaling.js';
40
49
  import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
41
50
  import { normalizeKnownCwdPath } from './cwd-picker.js';
@@ -48,6 +57,9 @@ import { refreshCliInstallSections } from './cli-install-ui.js';
48
57
  import { refreshUpdateSection } from './update-ui.js';
49
58
  import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
50
59
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
60
+ import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
61
+ import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
62
+ import { syncWindowRetentionSlider } from './conversation-window-retention.mjs';
51
63
  import {
52
64
  getPreviews,
53
65
  renderPreviewRowsInto,
@@ -199,6 +211,74 @@ export async function refreshBackgroundTaskTimeoutSetting() {
199
211
  }
200
212
  }
201
213
 
214
+ // Minutes without output after which a background task's row turns amber.
215
+ // Starts at the relay's default; bounds come from the API response.
216
+ let taskStallMinutes = DEFAULT_TASK_STALL_MINUTES;
217
+ let taskStallMinutesUpdateInFlight = false;
218
+
219
+ export function formatTaskStallMinutesLabel(value) {
220
+ const minutes = Math.max(1, Math.round(Number(value) || DEFAULT_TASK_STALL_MINUTES));
221
+ return `${minutes} min`;
222
+ }
223
+
224
+ function syncTaskStallMinutesSlider() {
225
+ const slider = document.getElementById('task-stall-minutes-slider');
226
+ const label = document.getElementById('task-stall-minutes-value');
227
+ if (slider instanceof HTMLInputElement) {
228
+ slider.value = String(taskStallMinutes);
229
+ slider.disabled = taskStallMinutesUpdateInFlight;
230
+ }
231
+ if (label) label.textContent = formatTaskStallMinutesLabel(taskStallMinutes);
232
+ }
233
+
234
+ /** Live label feedback while dragging; nothing is persisted until change fires. */
235
+ export function previewTaskStallMinutesSetting(value) {
236
+ const label = document.getElementById('task-stall-minutes-value');
237
+ if (label) label.textContent = formatTaskStallMinutesLabel(value);
238
+ }
239
+
240
+ export async function refreshTaskStallMinutesSetting() {
241
+ syncTaskStallMinutesSlider();
242
+ try {
243
+ const result = await loadTaskStallMinutesSetting();
244
+ if (!result || !Number.isFinite(Number(result.minutes))) return;
245
+ taskStallMinutes = Number(result.minutes);
246
+ setTaskStallThreshold(taskStallMinutes);
247
+ const slider = document.getElementById('task-stall-minutes-slider');
248
+ if (slider instanceof HTMLInputElement) {
249
+ if (Number.isFinite(Number(result.min))) slider.min = String(result.min);
250
+ if (Number.isFinite(Number(result.max))) slider.max = String(result.max);
251
+ }
252
+ } catch {
253
+ // Keep the last known value; the setting is not critical.
254
+ } finally {
255
+ syncTaskStallMinutesSlider();
256
+ }
257
+ }
258
+
259
+ export async function updateTaskStallMinutesSetting(value) {
260
+ if (taskStallMinutesUpdateInFlight) {
261
+ syncTaskStallMinutesSlider();
262
+ return;
263
+ }
264
+ const requested = Math.max(1, Math.round(Number(value) || DEFAULT_TASK_STALL_MINUTES));
265
+ taskStallMinutesUpdateInFlight = true;
266
+ syncTaskStallMinutesSlider();
267
+ try {
268
+ const result = await requestTaskStallMinutesSetting(requested);
269
+ if (!result) throw new Error('Failed to update when a background task counts as quiet.');
270
+ taskStallMinutes = Number.isFinite(Number(result.minutes)) ? Number(result.minutes) : requested;
271
+ // The open conversation's rows follow at once, not on the next load.
272
+ setTaskStallThreshold(taskStallMinutes);
273
+ showTransientRelayNotice(`Background tasks are marked as quiet after ${formatTaskStallMinutesLabel(taskStallMinutes)} without output.`);
274
+ } catch (error) {
275
+ alert(error?.message || 'Failed to update when a background task counts as quiet.');
276
+ } finally {
277
+ taskStallMinutesUpdateInFlight = false;
278
+ syncTaskStallMinutesSlider();
279
+ }
280
+ }
281
+
202
282
  // Mirrors shared/subagent-record-retention.mjs (the browser cannot import it):
203
283
  // the select starts at the relay's default until the relay's value loads.
204
284
  const DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS = 90;
@@ -476,6 +556,7 @@ let claudeSettingsState = {
476
556
  enabled: false,
477
557
  model: 'claude-sonnet-5',
478
558
  models: [],
559
+ backgroundCommandLimitMinutes: DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES,
479
560
  };
480
561
  let claudeSettingsInputsDirty = false;
481
562
 
@@ -489,7 +570,7 @@ function ensureClaudeSettingsInputTracking() {
489
570
  }
490
571
 
491
572
  function setClaudeSettingsControlsDisabled(disabled) {
492
- for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-save-btn']) {
573
+ for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-background-command-limit-slider', 'claude-save-btn']) {
493
574
  const element = document.getElementById(id);
494
575
  if (element) element.disabled = disabled;
495
576
  }
@@ -504,6 +585,9 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
504
585
  models: Array.isArray(settings?.models) ? settings.models : claudeSettingsState.models,
505
586
  attributionMode: ['oar', 'vanilla', 'off'].includes(settings?.attributionMode) ? settings.attributionMode : (claudeSettingsState.attributionMode || 'oar'),
506
587
  attributionExample: String(settings?.attributionExample || claudeSettingsState.attributionExample || '').trim(),
588
+ backgroundCommandLimitMinutes: Number(settings?.backgroundCommandLimitMinutes) > 0
589
+ ? Number(settings.backgroundCommandLimitMinutes)
590
+ : (claudeSettingsState.backgroundCommandLimitMinutes || DEFAULT_CLAUDE_BACKGROUND_COMMAND_LIMIT_MINUTES),
507
591
  };
508
592
  const modelInput = document.getElementById('claude-model-input');
509
593
  const toggle = document.getElementById('claude-enabled-toggle');
@@ -511,6 +595,15 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
511
595
  const attributionSelect = document.getElementById('claude-attribution-select');
512
596
  if (attributionSelect && (!claudeSettingsInputsDirty || resetInputs)) attributionSelect.value = claudeSettingsState.attributionMode;
513
597
  renderClaudeAttributionExample(attributionSelect?.value || claudeSettingsState.attributionMode);
598
+ const limitSlider = document.getElementById('claude-background-command-limit-slider');
599
+ if (limitSlider instanceof HTMLInputElement) {
600
+ // Bounds come from the relay; the markup's are only the pre-load copy.
601
+ for (const [attribute, field] of [['min', 'backgroundCommandLimitMinMinutes'], ['max', 'backgroundCommandLimitMaxMinutes'], ['step', 'backgroundCommandLimitStepMinutes']]) {
602
+ if (Number(settings?.[field]) > 0) limitSlider[attribute] = String(settings[field]);
603
+ }
604
+ if (!claudeSettingsInputsDirty || resetInputs) limitSlider.value = String(claudeSettingsState.backgroundCommandLimitMinutes);
605
+ }
606
+ renderClaudeBackgroundCommandLimitLabel(limitSlider?.value || claudeSettingsState.backgroundCommandLimitMinutes);
514
607
  if (modelInput && (!claudeSettingsInputsDirty || resetInputs)) modelInput.value = claudeSettingsState.model;
515
608
  if (resetInputs) claudeSettingsInputsDirty = false;
516
609
  if (toggle) {
@@ -558,6 +651,19 @@ function renderClaudeAttributionExample(mode) {
558
651
  example.textContent = claudeAttributionExampleText(mode, claudeSettingsState.attributionExample);
559
652
  }
560
653
 
654
+ function renderClaudeBackgroundCommandLimitLabel(minutes) {
655
+ const label = document.getElementById('claude-background-command-limit-value');
656
+ if (label) label.textContent = formatClaudeBackgroundCommandLimitLabel(minutes);
657
+ }
658
+
659
+ /** Live label feedback while dragging; saved with the other Claude settings. */
660
+ export function previewClaudeBackgroundCommandLimit(value) {
661
+ claudeSettingsInputsDirty = true;
662
+ renderClaudeBackgroundCommandLimitLabel(value);
663
+ }
664
+
665
+ window.previewClaudeBackgroundCommandLimit = previewClaudeBackgroundCommandLimit;
666
+
561
667
  window.previewClaudeAttribution = (mode) => {
562
668
  claudeSettingsInputsDirty = true;
563
669
  renderClaudeAttributionExample(mode);
@@ -569,10 +675,12 @@ export async function saveClaudeSettings() {
569
675
  const model = String(modelInput?.value || '').trim() || 'claude-sonnet-5';
570
676
  const attributionSelect = document.getElementById('claude-attribution-select');
571
677
  const attributionMode = ['oar', 'vanilla', 'off'].includes(attributionSelect?.value) ? attributionSelect.value : undefined;
678
+ const limitSlider = document.getElementById('claude-background-command-limit-slider');
679
+ const backgroundCommandLimitMinutes = Number(limitSlider?.value) > 0 ? Number(limitSlider.value) : undefined;
572
680
  claudeSettingsUpdateInFlight = true;
573
681
  setClaudeSettingsControlsDisabled(true);
574
682
  try {
575
- const result = await updateClaudeSettings({ model, attributionMode });
683
+ const result = await updateClaudeSettings({ model, attributionMode, backgroundCommandLimitMinutes });
576
684
  if (!result) throw new Error('Failed to save Claude settings.');
577
685
  applyClaudeSettingsState(result, { resetInputs: true });
578
686
  showTransientRelayNotice(
@@ -1337,15 +1445,33 @@ export async function removeOpenAISettings() {
1337
1445
  }
1338
1446
  }
1339
1447
 
1448
+ const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
1449
+ action: 'what it runs',
1450
+ 'working-directory': 'working folder',
1451
+ 'logon-type': 'logon type',
1452
+ trigger: 'trigger',
1453
+ 'boot-delay': 'boot delay',
1454
+ launcher: 'launcher edited or from an older OAR',
1455
+ 'launcher-missing': 'launcher missing',
1456
+ });
1457
+
1340
1458
  function windowsAutostartStatusText() {
1341
1459
  const boot = windowsAutostartState?.boot;
1342
1460
  if (boot?.pendingElevation) {
1343
1461
  return 'Confirm the admin prompt on the PC… (it only appears on the physical desktop)';
1344
1462
  }
1345
1463
  if (boot?.lastError) return boot.lastError;
1464
+ if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
1346
1465
  if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
1347
1466
  return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
1348
1467
  }
1468
+ if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
1469
+ const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
1470
+ return `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates both and keeps a changed launcher as a .bak copy.`;
1471
+ }
1472
+ if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
1473
+ return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
1474
+ }
1349
1475
  if (boot?.taskStatus === 'foreign') {
1350
1476
  return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
1351
1477
  }
@@ -1425,7 +1551,7 @@ export async function refreshWindowsAutostartSetting() {
1425
1551
  }
1426
1552
  windowsAutostartState = result;
1427
1553
  } catch (error) {
1428
- alert(error?.message || 'Failed to read the Windows autostart setting.');
1554
+ alert(error?.message ? `Failed to read the Windows autostart setting: ${error.message}` : 'Failed to read the Windows autostart setting.');
1429
1555
  } finally {
1430
1556
  windowsAutostartUpdateInFlight = false;
1431
1557
  syncWindowsAutostartSetting();
@@ -1459,7 +1585,7 @@ export async function updateWindowsAutostartMode(mode) {
1459
1585
  );
1460
1586
  }
1461
1587
  } catch (error) {
1462
- alert(error?.message || 'Failed to update the Windows autostart setting.');
1588
+ alert(error?.message ? `Failed to update the Windows autostart setting: ${error.message}` : 'Failed to update the Windows autostart setting.');
1463
1589
  } finally {
1464
1590
  windowsAutostartUpdateInFlight = false;
1465
1591
  syncWindowsAutostartSetting();
@@ -1579,9 +1705,17 @@ async function restartRelayForFeatureFlags() {
1579
1705
  featureFlagsUpdateInFlight = true;
1580
1706
  syncFeatureFlagsSection();
1581
1707
  try {
1582
- const result = await requestRelayRestart({ reason: 'feature-flags-changed' });
1583
- if (!result) {
1584
- alert('Failed to request a relay restart. Restarts can only be requested from the relay host itself.');
1708
+ let result = null;
1709
+ try {
1710
+ result = await requestRelayRestart({ reason: 'feature-flags-changed' });
1711
+ } catch (error) {
1712
+ const { kind, text } = describeRelayRestartError(error);
1713
+ if (kind === 'refused') alert(text);
1714
+ else showTransientRelayNotice(text, 9000);
1715
+ return;
1716
+ }
1717
+ if (!result?.ok) {
1718
+ alert('Failed to request a relay restart.');
1585
1719
  return;
1586
1720
  }
1587
1721
  showTransientRelayNotice('Relay restart requested. It will restart once the running turns finish; queued messages wait for it.');
@@ -1684,14 +1818,15 @@ export async function updateDefaultSessionWorkspaceRootSetting(rawValue) {
1684
1818
  }
1685
1819
 
1686
1820
  // "Live previews": every preview across every session, not just this
1687
- // conversation's. Previews never expire, so this list is the one place that can
1688
- // close a link left behind in a conversation you have moved on from.
1821
+ // conversation's. Previews never expire and survive a relay restart, so this
1822
+ // list is the one place that can close a link left behind in a conversation
1823
+ // you have moved on from; age and last use show which ones are stale.
1689
1824
  export function renderPreviewsSection() {
1690
1825
  const list = document.getElementById('settings-previews-list');
1691
1826
  const empty = document.getElementById('settings-previews-empty');
1692
1827
  if (!list) return;
1693
1828
  const previews = getPreviews();
1694
- renderPreviewRowsInto(list, previews, { withConversation: true });
1829
+ renderPreviewRowsInto(list, previews, { withConversation: true, withAge: true });
1695
1830
  if (empty) {
1696
1831
  empty.hidden = previews.length > 0;
1697
1832
  empty.textContent = 'No previews are published right now.';
@@ -1726,6 +1861,8 @@ export function openSettingsModal(tab, providerTab) {
1726
1861
  themeSelect.value = readLocalStorage(THEME_STORAGE_KEY) === 'light' ? 'light' : 'dark';
1727
1862
  }
1728
1863
  syncSuspendHostVisibility();
1864
+ syncScrollOnSendToggle();
1865
+ syncWindowRetentionSlider();
1729
1866
  syncFontScaleSelect();
1730
1867
  syncPwaAppNameInput();
1731
1868
  syncDefaultSessionWorkspaceRootInput();
@@ -1758,10 +1895,13 @@ export function openSettingsModal(tab, providerTab) {
1758
1895
  void refreshTurnCeilingSetting();
1759
1896
  syncBackgroundTaskTimeoutSlider();
1760
1897
  void refreshBackgroundTaskTimeoutSetting();
1898
+ syncTaskStallMinutesSlider();
1899
+ void refreshTaskStallMinutesSetting();
1761
1900
  syncSubagentRecordRetentionSelect();
1762
1901
  void refreshSubagentRecordRetentionSetting();
1763
1902
  void refreshPushSettingsSection();
1764
1903
  void refreshPreviewsSection();
1904
+ void refreshSharedConversationsSection();
1765
1905
  void refreshRemoteRelaysSection();
1766
1906
  void refreshAgentSessionsSection();
1767
1907
  void refreshFeatureFlagsSection();
@@ -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', 'relays', 'previews', 'notifications', 'features'];
4
+ const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', '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', 'claude-cloud', 'grok', 'cursor'];
@@ -0,0 +1,49 @@
1
+ // Text for Settings → Sharing: a share row's detail line and an access log
2
+ // entry. DOM-free so it is unit-testable.
3
+
4
+ // The same steps as a preview's age (preview-cards.mjs), which this module
5
+ // cannot import without the API client.
6
+ function age(iso, now) {
7
+ const at = Date.parse(String(iso || ''));
8
+ if (!Number.isFinite(at) || at <= 0) return '';
9
+ const minutes = Math.floor(Math.max(0, now - at) / 60_000);
10
+ if (minutes < 1) return 'just now';
11
+ if (minutes < 60) return `${minutes} min ago`;
12
+ const hours = Math.floor(minutes / 60);
13
+ if (hours < 48) return `${hours} h ago`;
14
+ return `${Math.floor(hours / 24)} d ago`;
15
+ }
16
+
17
+ function plural(count, one, many) {
18
+ return `${count} ${count === 1 ? one : many}`;
19
+ }
20
+
21
+ /** "shared 3 d ago · new link 2 h ago · last opened 5 min ago · 4 opens · 2 viewers" */
22
+ export function describeShare(share, now = Date.now()) {
23
+ const parts = [];
24
+ const since = age(share?.sharedSince, now);
25
+ if (since) parts.push(`shared ${since}`);
26
+ const renewed = age(share?.renewedAt, now);
27
+ if (renewed) parts.push(`new link ${renewed}`);
28
+ const opened = age(share?.lastOpenedAt, now);
29
+ parts.push(opened ? `last opened ${opened}` : 'never opened');
30
+ const opens = Math.max(0, Math.trunc(Number(share?.openCount) || 0));
31
+ const viewers = Math.max(0, Math.trunc(Number(share?.viewerCount) || 0));
32
+ parts.push(plural(opens, 'open', 'opens'));
33
+ parts.push(plural(viewers, 'viewer', 'viewers'));
34
+ if (share?.archived) parts.push('archived');
35
+ return parts.join(' · ');
36
+ }
37
+
38
+ /** "16 Oct 2026, 14:05 · DE · 203.0.113.7 · viewer 1a2b3c4d" */
39
+ export function describeShareOpen(open, { locale } = {}) {
40
+ const parts = [];
41
+ const at = Date.parse(String(open?.openedAt || ''));
42
+ if (Number.isFinite(at)) {
43
+ parts.push(new Date(at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }));
44
+ }
45
+ if (open?.country) parts.push(String(open.country));
46
+ parts.push(open?.ip ? String(open.ip) : 'IP not stored');
47
+ if (open?.viewer) parts.push(`viewer ${open.viewer}`);
48
+ return parts.join(' · ');
49
+ }
@@ -0,0 +1,167 @@
1
+ // "Preview as shared": the relay marks, in the payload of its preview routes,
2
+ // every redaction (U+E000 … U+E001, the text a viewer gets in between) and
3
+ // every leftover that still looks like a path (U+E002 … U+E003). See
4
+ // server/services/share-path-redaction.mjs. This module turns the marks into
5
+ // highlights wherever the transcript put the text: reply markdown, thoughts,
6
+ // tool lines, code blocks, the sub-agent viewer. A highlight can be split by
7
+ // the elements a renderer put inside it (syntax highlighting); each piece is
8
+ // marked on its own. Marks inside attributes (a title, a link) are dropped.
9
+
10
+ export const PREVIEW_MARK_CHARS = Object.freeze({
11
+ redactedStart: '',
12
+ redactedEnd: '',
13
+ suspectStart: '',
14
+ suspectEnd: '',
15
+ });
16
+
17
+ const ANY_MARK_RE = /[-]/;
18
+ const ALL_MARKS_RE = /[-]/g;
19
+ const BLOCK_SELECTOR = 'p, li, pre, div, td, th, h1, h2, h3, h4, h5, h6, blockquote, summary';
20
+ const MARK_KINDS = {
21
+ '': { open: 'redacted' },
22
+ '': { close: 'redacted' },
23
+ '': { open: 'suspect' },
24
+ '': { close: 'suspect' },
25
+ };
26
+ const CLASS_BY_KIND = {
27
+ redacted: 'share-preview-redacted',
28
+ suspect: 'share-preview-suspect',
29
+ };
30
+ const TITLE_BY_KIND = {
31
+ redacted: 'Hidden from shared viewers: they see this instead',
32
+ suspect: 'Still looks like a path: shared viewers see it as it is',
33
+ };
34
+
35
+ /** The text without any mark. */
36
+ export function stripPreviewMarks(text) {
37
+ return String(text ?? '').replace(ALL_MARKS_RE, '');
38
+ }
39
+
40
+ /**
41
+ * The pieces of a text between marks, each with the highlight it stands in
42
+ * (null for plain text). `state` carries an open highlight from the text
43
+ * before (a highlight split across nodes) and is updated in place.
44
+ */
45
+ export function splitPreviewMarks(text, state = { kind: null }) {
46
+ const pieces = [];
47
+ let buffer = '';
48
+ const flush = () => {
49
+ if (buffer) pieces.push({ text: buffer, kind: state.kind });
50
+ buffer = '';
51
+ };
52
+ for (const ch of String(text ?? '')) {
53
+ const mark = MARK_KINDS[ch];
54
+ if (!mark) {
55
+ buffer += ch;
56
+ continue;
57
+ }
58
+ flush();
59
+ if (mark.open) state.kind = mark.open;
60
+ else if (mark.close && state.kind === mark.close) state.kind = null;
61
+ }
62
+ flush();
63
+ return pieces;
64
+ }
65
+
66
+ function markElement(doc, piece) {
67
+ const element = doc.createElement('mark');
68
+ element.className = CLASS_BY_KIND[piece.kind];
69
+ element.title = TITLE_BY_KIND[piece.kind];
70
+ element.textContent = piece.text;
71
+ return element;
72
+ }
73
+
74
+ function rewriteTextNode(node, state) {
75
+ const value = node.nodeValue || '';
76
+ if (!ANY_MARK_RE.test(value) && !state.kind) return;
77
+ const pieces = splitPreviewMarks(value, state);
78
+ const doc = node.ownerDocument;
79
+ const fragment = doc.createDocumentFragment();
80
+ for (const piece of pieces) {
81
+ fragment.appendChild(piece.kind ? markElement(doc, piece) : doc.createTextNode(piece.text));
82
+ }
83
+ node.parentNode?.replaceChild(fragment, node);
84
+ }
85
+
86
+ function stripAttributes(root) {
87
+ const elements = root.nodeType === 1 ? [root, ...root.querySelectorAll('*')] : [...(root.querySelectorAll?.('*') || [])];
88
+ for (const element of elements) {
89
+ for (const attribute of [...(element.attributes || [])]) {
90
+ if (ANY_MARK_RE.test(attribute.value)) element.setAttribute(attribute.name, stripPreviewMarks(attribute.value));
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Turn every mark under `root` into a highlight. Idempotent. */
96
+ export function applyPreviewMarks(root) {
97
+ if (!root) return;
98
+ if (root.nodeType === 3) {
99
+ if (root.parentElement) applyPreviewMarks(root.parentElement);
100
+ return;
101
+ }
102
+ const doc = root.ownerDocument || root;
103
+ if (ANY_MARK_RE.test(root.textContent || '')) {
104
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */, {
105
+ acceptNode(node) {
106
+ const tag = String(node.parentElement?.tagName || '').toUpperCase();
107
+ // Mark characters are never part of a script or a style; leave them.
108
+ return tag === 'SCRIPT' || tag === 'STYLE' ? 2 /* FILTER_REJECT */ : 1 /* FILTER_ACCEPT */;
109
+ },
110
+ });
111
+ const nodes = [];
112
+ while (walker.nextNode()) nodes.push(walker.currentNode);
113
+ // An open highlight runs on in document order; a block element ends it.
114
+ let state = { kind: null };
115
+ let block = null;
116
+ for (const node of nodes) {
117
+ const nodeBlock = node.parentElement?.closest?.(BLOCK_SELECTOR) || null;
118
+ if (nodeBlock !== block) {
119
+ state = { kind: null };
120
+ block = nodeBlock;
121
+ }
122
+ rewriteTextNode(node, state);
123
+ }
124
+ }
125
+ stripAttributes(root);
126
+ }
127
+
128
+ /**
129
+ * Keep marking whatever the page renders under `root` (the transcript is
130
+ * rebuilt as the preview polls, the sub-agent viewer fills in). Only what
131
+ * changed is looked at. Returns a stop function.
132
+ */
133
+ export function watchPreviewMarks(root, { schedule = (fn) => requestAnimationFrame(fn) } = {}) {
134
+ if (!root || typeof MutationObserver !== 'function') return () => {};
135
+ const changed = new Set();
136
+ let pending = false;
137
+ let observer = null;
138
+ const observe = () => observer.observe(root, {
139
+ childList: true, subtree: true, characterData: true, attributes: true,
140
+ });
141
+ const run = () => {
142
+ pending = false;
143
+ observer.disconnect();
144
+ try {
145
+ for (const node of changed) {
146
+ if (node.isConnected) applyPreviewMarks(node);
147
+ }
148
+ changed.clear();
149
+ if (ANY_MARK_RE.test(document.title || '')) document.title = stripPreviewMarks(document.title);
150
+ } finally {
151
+ observe();
152
+ }
153
+ };
154
+ observer = new MutationObserver((records) => {
155
+ for (const record of records) {
156
+ if (record.type === 'childList') record.addedNodes.forEach((node) => changed.add(node));
157
+ else if (record.type === 'characterData') changed.add(record.target);
158
+ else if (record.type === 'attributes' && ANY_MARK_RE.test(record.target.getAttribute?.(record.attributeName) || '')) changed.add(record.target);
159
+ }
160
+ if (pending || !changed.size) return;
161
+ pending = true;
162
+ schedule(run);
163
+ });
164
+ applyPreviewMarks(root);
165
+ observe();
166
+ return () => observer.disconnect();
167
+ }
@@ -0,0 +1,109 @@
1
+ // The relay's active public shares, as it last reported them: one entry per
2
+ // shared conversation (Settings → Shared conversations) and whether the open
3
+ // conversation has one (the ⋯ menu's "Stop sharing"). Read from GET /api/shares
4
+ // when the socket connects and again on every share event; no DOM here.
5
+
6
+ import { listConversationShares } from './api-client.js';
7
+
8
+ let shares = [];
9
+ let settings = { storeViewerIp: true, accessLog: true };
10
+ let loaded = false;
11
+ let refreshInFlight = null;
12
+ let refreshAgain = false;
13
+ const listeners = new Set();
14
+
15
+ function notify() {
16
+ for (const listener of listeners) {
17
+ try { listener(); } catch (error) { console.error('share listener failed', error); }
18
+ }
19
+ }
20
+
21
+ function normalizeShare(entry) {
22
+ const conversationId = String(entry?.conversationId || '').trim();
23
+ if (!conversationId) return null;
24
+ const count = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.trunc(Number(value))) : 0);
25
+ return {
26
+ conversationId,
27
+ title: String(entry?.title || '').trim(),
28
+ archived: entry?.archived === true,
29
+ shareUrl: String(entry?.shareUrl || '').trim(),
30
+ sharedSince: entry?.sharedSince || null,
31
+ renewedAt: entry?.renewedAt || null,
32
+ lastOpenedAt: entry?.lastOpenedAt || null,
33
+ openCount: count(entry?.openCount),
34
+ viewerCount: count(entry?.viewerCount),
35
+ };
36
+ }
37
+
38
+ /** Takes a GET /api/shares body (or the same shape). */
39
+ export function applySharesSnapshot(payload) {
40
+ if (!payload || typeof payload !== 'object') return;
41
+ shares = (Array.isArray(payload.shares) ? payload.shares : []).map(normalizeShare).filter(Boolean);
42
+ settings = {
43
+ storeViewerIp: payload.storeViewerIp !== false,
44
+ accessLog: payload.accessLog !== false,
45
+ };
46
+ loaded = true;
47
+ notify();
48
+ }
49
+
50
+ export function applyShareSettings(next) {
51
+ if (!next || typeof next.storeViewerIp !== 'boolean') return;
52
+ settings = { ...settings, storeViewerIp: next.storeViewerIp };
53
+ notify();
54
+ }
55
+
56
+ /** A share this page just stopped: gone before the relay's event arrives. */
57
+ export function forgetShare(conversationId) {
58
+ const id = String(conversationId || '').trim();
59
+ const next = shares.filter((share) => share.conversationId !== id);
60
+ if (next.length === shares.length) return;
61
+ shares = next;
62
+ notify();
63
+ }
64
+
65
+ export function getShares() {
66
+ return shares.slice();
67
+ }
68
+
69
+ export function getShareSettings() {
70
+ return { ...settings };
71
+ }
72
+
73
+ export function sharesLoaded() {
74
+ return loaded;
75
+ }
76
+
77
+ export function hasActiveShare(conversationId) {
78
+ const id = String(conversationId || '').trim();
79
+ return !!id && shares.some((share) => share.conversationId === id);
80
+ }
81
+
82
+ /**
83
+ * Reads the list again. Calls during a read are folded into one more read
84
+ * after it, so the last event always wins. Rejects with the relay's reason.
85
+ */
86
+ export async function refreshShares() {
87
+ if (refreshInFlight) {
88
+ refreshAgain = true;
89
+ return refreshInFlight;
90
+ }
91
+ refreshInFlight = (async () => {
92
+ try {
93
+ do {
94
+ refreshAgain = false;
95
+ const payload = await listConversationShares();
96
+ if (payload) applySharesSnapshot(payload);
97
+ } while (refreshAgain);
98
+ } finally {
99
+ refreshInFlight = null;
100
+ }
101
+ })();
102
+ return refreshInFlight;
103
+ }
104
+
105
+ export function subscribeShares(listener) {
106
+ if (typeof listener !== 'function') return () => {};
107
+ listeners.add(listener);
108
+ return () => listeners.delete(listener);
109
+ }