@oar-sh/oar 0.9.12 → 0.9.14

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 (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -6,8 +6,19 @@
6
6
  // host action" setting on purpose: a device with the action hidden must still
7
7
  // be able to see and cancel a queued suspend.
8
8
 
9
- import { escHtml, showTransientRelayNotice } from './store.js';
10
- import { cancelHostSuspend, cancelRelayRestart } from './api-client.js';
9
+ import { showTransientRelayNotice } from './store.js';
10
+ import { cancelHostSuspend } from './api-client.js';
11
+ import {
12
+ cancelQueuedRelayRestart,
13
+ isRelayRestartRequestInFlight,
14
+ keepWaitingForQueuedRelayRestart,
15
+ noteRelayRestartStateForDialogs,
16
+ openRestartNowConfirmation,
17
+ refreshRelayRestartWaitedLabels,
18
+ relayRestartHeadline,
19
+ renderRelayRestartRow,
20
+ setRelayRestartStateSink,
21
+ } from './relay-restart-ui.js';
11
22
 
12
23
  const BANNER_ID = 'pending-action-banner';
13
24
  const SUSPEND_MENU_ID = 'chat-menu-suspend-host';
@@ -20,6 +31,7 @@ const RESTART_MENU_PENDING_LABEL = '🌄 Restart pending…';
20
31
  let hostSuspendState = null;
21
32
  let relayShutdownState = null;
22
33
  let countdownTimer = null;
34
+ let waitedTimer = null;
23
35
  let cancelInFlight = false;
24
36
 
25
37
  export function getHostSuspendState() {
@@ -96,20 +108,10 @@ export function hostSuspendBannerText(state, nowMs = Date.now()) {
96
108
  : '💤 Suspend queued — waiting for running agents to finish';
97
109
  }
98
110
 
111
+ /** The restart row's headline (relay-restart-ui.js renders the whole row). */
99
112
  export function relayShutdownBannerText(state) {
100
113
  if (!isRelayShutdownPending(state)) return '';
101
- const action = state.action === 'restart' || state.restart ? 'restart' : 'shutdown';
102
- const icon = action === 'restart' ? '🌄' : '⏻';
103
- if (String(state.status || '') === 'shutting_down') {
104
- return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
105
- }
106
- // Only the processing turns hold it; queued messages wait for the restart.
107
- const queue = state.queue || {};
108
- const turns = Number(queue.processingCount || 0);
109
- const held = Number(queue.pendingCount || 0) + Number(queue.parkedCount || 0);
110
- const waiting = turns > 0 ? `waiting for ${turns} running turn${turns === 1 ? '' : 's'} to finish` : 'nothing is running, going down now';
111
- const queued = held > 0 ? `; ${held} queued message${held === 1 ? '' : 's'} wait${held === 1 ? 's' : ''} for it` : '';
112
- return `${icon} Relay ${action} queued — ${waiting}${queued}`;
114
+ return relayRestartHeadline(state);
113
115
  }
114
116
 
115
117
  function ensureBanner() {
@@ -165,30 +167,6 @@ async function runCancelHostSuspend() {
165
167
  }
166
168
  }
167
169
 
168
- async function runCancelRelayShutdown() {
169
- if (cancelInFlight) return;
170
- cancelInFlight = true;
171
- renderPendingActionBanner();
172
- try {
173
- let result = null;
174
- try {
175
- result = await cancelRelayRestart();
176
- } catch (error) {
177
- showTransientRelayNotice(`Failed to cancel the queued relay restart: ${error?.message || 'unknown error'}`, 6000);
178
- return;
179
- }
180
- if (!result?.ok) {
181
- showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
182
- return;
183
- }
184
- applyRelayShutdownState(result);
185
- showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled.' : 'No relay restart was pending.', 4000);
186
- } finally {
187
- cancelInFlight = false;
188
- renderPendingActionBanner();
189
- }
190
- }
191
-
192
170
  function syncCountdownTimer() {
193
171
  const needsTicker = isHostSuspendPending() && String(hostSuspendState?.status || '') === 'countdown' && hostSuspendState?.fireAt;
194
172
  if (needsTicker && !countdownTimer) {
@@ -197,8 +175,27 @@ function syncCountdownTimer() {
197
175
  clearInterval(countdownTimer);
198
176
  countdownTimer = null;
199
177
  }
178
+ // The restart's waited time ticks in place: rebuilding the row every
179
+ // second would swallow a click on its buttons.
180
+ const needsWaitedTicker = String(relayShutdownState?.status || '') === 'queued';
181
+ if (needsWaitedTicker && !waitedTimer) {
182
+ waitedTimer = setInterval(() => refreshRelayRestartWaitedLabels(relayShutdownState), 15_000);
183
+ waitedTimer?.unref?.();
184
+ } else if (!needsWaitedTicker && waitedTimer) {
185
+ clearInterval(waitedTimer);
186
+ waitedTimer = null;
187
+ }
200
188
  }
201
189
 
190
+ const relayRestartHandlers = {
191
+ onOpen: (conversationId) => {
192
+ if (conversationId && typeof window.openConversation === 'function') void window.openConversation(conversationId);
193
+ },
194
+ onRestartNow: () => openRestartNowConfirmation(relayShutdownState),
195
+ onKeepWaiting: () => keepWaitingForQueuedRelayRestart(),
196
+ onCancel: () => cancelQueuedRelayRestart(),
197
+ };
198
+
202
199
  export function renderPendingActionBanner() {
203
200
  if (typeof document === 'undefined') return;
204
201
  const rows = [];
@@ -211,12 +208,9 @@ export function renderPendingActionBanner() {
211
208
  }));
212
209
  }
213
210
  if (isRelayShutdownPending()) {
214
- const restarting = String(relayShutdownState?.status || '') === 'shutting_down';
215
- rows.push(bannerRow({
216
- key: 'relay-shutdown',
217
- text: relayShutdownBannerText(relayShutdownState),
218
- cancelLabel: restarting ? '' : 'Cancel',
219
- onCancel: runCancelRelayShutdown,
211
+ rows.push(renderRelayRestartRow(relayShutdownState, {
212
+ handlers: relayRestartHandlers,
213
+ busy: isRelayRestartRequestInFlight(),
220
214
  }));
221
215
  }
222
216
  const banner = document.getElementById(BANNER_ID) || (rows.length ? ensureBanner() : null);
@@ -246,6 +240,7 @@ export function applyHostSuspendState(state) {
246
240
 
247
241
  export function applyRelayShutdownState(state) {
248
242
  relayShutdownState = state && typeof state === 'object' ? state : null;
243
+ noteRelayRestartStateForDialogs(relayShutdownState);
249
244
  renderPendingActionBanner();
250
245
  }
251
246
 
@@ -268,6 +263,11 @@ export function applyPendingActionsFromStatus(status) {
268
263
  }
269
264
 
270
265
  export function initHostSuspendUi() {
266
+ // A cancel, keep-waiting or restart-now answer carries the new state.
267
+ setRelayRestartStateSink((state, { rerenderOnly = false } = {}) => {
268
+ if (rerenderOnly) renderPendingActionBanner();
269
+ else applyRelayShutdownState(state);
270
+ });
271
271
  renderPendingActionBanner();
272
272
  }
273
273
 
@@ -276,6 +276,7 @@ export function __resetHostSuspendUiForTests() {
276
276
  relayShutdownState = null;
277
277
  cancelInFlight = false;
278
278
  if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
279
+ if (waitedTimer) { clearInterval(waitedTimer); waitedTimer = null; }
279
280
  document.getElementById(BANNER_ID)?.remove();
280
281
  syncMenuLabels();
281
282
  }
@@ -82,6 +82,7 @@ import { mergeTailPage } from './transcript-reading-anchor.mjs';
82
82
  import { readWindowRetention } from './conversation-window-retention.mjs';
83
83
  import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
84
84
  import {
85
+ isBackgroundTasksSnapshotCurrent,
85
86
  setBackgroundTasksConversation,
86
87
  setConversationBackgroundTasks,
87
88
  setConversationEndedBackgroundTasks,
@@ -777,6 +778,8 @@ export function toggleConversationGroupFold(event, groupId) {
777
778
  // rather than drawn anew (mergeMessagesIntoTranscript).
778
779
  // windowPayload: with keepTranscript, the window the page keeps, kept for
779
780
  // the next visit in place of nothing.
781
+ // backgroundTasksAsOf: backgroundTasksSnapshotMark(id) as the request left;
782
+ // the payload's task sets are left out when an event brought newer ones since.
780
783
  export function applyLoadedConversationState(id, response, {
781
784
  restoreScroll = false,
782
785
  savedScrollTop = null,
@@ -786,6 +789,7 @@ export function applyLoadedConversationState(id, response, {
786
789
  fromCache = false,
787
790
  mergeFrom = null,
788
791
  windowPayload = null,
792
+ backgroundTasksAsOf = null,
789
793
  } = {}) {
790
794
  if (!response) {
791
795
  if (!IS_SHARED_VIEW) conversationPayloadCache.forget(id);
@@ -892,9 +896,11 @@ export function applyLoadedConversationState(id, response, {
892
896
  setBackgroundTasksConversation(id);
893
897
  // Absent from an older relay's payload: the threshold keeps its default.
894
898
  if ('taskStallMinutes' in response) setConversationTaskStallMinutes(id, response.taskStallMinutes);
895
- setConversationBackgroundTasks(id, response.backgroundTasks || []);
896
- // Absent from an older relay's payload: keep what the socket said.
897
- if (Array.isArray(response.endedBackgroundTasks)) setConversationEndedBackgroundTasks(id, response.endedBackgroundTasks);
899
+ if (isBackgroundTasksSnapshotCurrent(id, backgroundTasksAsOf)) {
900
+ setConversationBackgroundTasks(id, response.backgroundTasks || []);
901
+ // Absent from an older relay's payload: keep what the socket said.
902
+ if (Array.isArray(response.endedBackgroundTasks)) setConversationEndedBackgroundTasks(id, response.endedBackgroundTasks);
903
+ }
898
904
  setUsageLimitConversation(id, {
899
905
  // Absent from an older relay's payload: keep what the socket said.
900
906
  pause: 'usageLimitPause' in response ? response.usageLimitPause : undefined,
@@ -11,6 +11,7 @@ const PUSH_EVENT_CONTROL_IDS = {
11
11
  board: 'push-event-board-toggle',
12
12
  cliOffline: 'push-event-cli-offline-toggle',
13
13
  hostSuspend: 'push-event-host-suspend-toggle',
14
+ relayRestart: 'push-event-relay-restart-toggle',
14
15
  };
15
16
 
16
17
  let pushUpdateInFlight = false;
@@ -0,0 +1,15 @@
1
+ // Browser mirror of questionAcceptsFreeText in shared/question-answer-rule.mjs
2
+ // (the relay's answer route applies the same rule). The browser cannot import
3
+ // shared/ (only server/public is served), so this is a hand-kept copy;
4
+ // question-answer-rule.test.mjs asserts it behaves exactly like the original.
5
+
6
+ /**
7
+ * Whether a card takes typed text. A card with choices takes it only when it
8
+ * says so (`allowFreeform: true`); a missing flag counts as false there, as
9
+ * the create route and the relay's question rows read it. Without choices a
10
+ * card always does: it could not be answered otherwise.
11
+ */
12
+ export function questionAcceptsFreeText(question) {
13
+ const choices = Array.isArray(question?.choices) ? question.choices : [];
14
+ return question?.allowFreeform === true || choices.length === 0;
15
+ }
@@ -14,14 +14,21 @@
14
14
  // answer is the selected labels joined with ", " — plus whatever the user
15
15
  // typed as an extra answer — which is Claude's own multi-select answer format.
16
16
 
17
+ import { questionAcceptsFreeText } from './question-answer-rule.mjs';
18
+
17
19
  function choiceCount(question) {
18
20
  return Array.isArray(question?.choices) ? question.choices.length : 0;
19
21
  }
20
22
 
21
- /** Whether the card offers the "Select several" switch. */
23
+ /**
24
+ * Whether the card offers the "Select several" switch. Never on a card that
25
+ * takes no typed text: the relay takes several choices there only when the
26
+ * provider flagged the card multi-select.
27
+ */
22
28
  export function offersMultiSelectToggle(question) {
23
29
  if (!question || typeof question !== 'object') return false;
24
- return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true;
30
+ return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true
31
+ && questionAcceptsFreeText(question);
25
32
  }
26
33
 
27
34
  /**
@@ -24,6 +24,7 @@ const EVENT_TYPES = new Set([
24
24
  'heartbeat',
25
25
  'link_lost',
26
26
  'link_restored',
27
+ 'relay_restarted',
27
28
  ]);
28
29
  const URGENT_TYPES = new Set(['question_asked', 'error']);
29
30
  const DIGEST_PATTERN = /^Digest of \d+ events?/;
@@ -43,6 +44,7 @@ const TYPE_LABELS = Object.freeze({
43
44
  heartbeat: 'heartbeat',
44
45
  link_lost: 'link lost',
45
46
  link_restored: 'link back',
47
+ relay_restarted: 'relay restarted',
46
48
  });
47
49
 
48
50
  function defaultEscape(value) {
@@ -181,6 +183,8 @@ export function watchersOf(subscriptions, conversationId, { includeAll = true }
181
183
  export function watchingChipText(summary) {
182
184
  if (!summary?.subscriptions?.length) return '';
183
185
  if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
186
+ // Only relay_restarted: no session is watched.
187
+ if (!summary.count) return 'waiting for a relay restart';
184
188
  return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
185
189
  }
186
190
 
@@ -198,7 +202,7 @@ function describeSubscription(sub, conversations) {
198
202
  const listed = (Array.isArray(sub.sessions) ? sub.sessions : []).filter((session) => session?.id).map(titleOf).join(', ');
199
203
  const what = sub.scope === 'all'
200
204
  ? (paired ? `All sessions of ${relay}` : 'All sessions')
201
- : (paired ? `${relay} · ${listed}` : listed);
205
+ : (paired ? `${relay} · ${listed}` : (listed || 'This relay'));
202
206
  const extras = [
203
207
  sub.once ? 'once' : '',
204
208
  sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
@@ -1,6 +1,6 @@
1
1
  import { stripRemotePromptHeader } from './remote-relay-shared.mjs';
2
2
 
3
- const RELAY_TOOL_GUIDANCE = [
3
+ const RELAY_TOOL_GUIDANCE_LINES = [
4
4
  '# Relay Tool Guidance',
5
5
  'For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.',
6
6
  'When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so the web relay can render clickable buttons. Only omit choices when the question genuinely requires freeform text input.',
@@ -8,7 +8,12 @@ const RELAY_TOOL_GUIDANCE = [
8
8
  'For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.',
9
9
  'Note: shutdown is queued and only completes when the current turn finishes, so it is pointless to wait for it to interrupt an active turn.',
10
10
  'Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.',
11
- ].join(' ');
11
+ 'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.',
12
+ ];
13
+ const RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE_LINES.join(' ');
14
+ // Prompts stored before the `immediate` line was added carry the guidance
15
+ // without it.
16
+ const PREVIOUS_RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE_LINES.slice(0, -1).join(' ');
12
17
 
13
18
  const MODE_MARKERS = {
14
19
  plan: '[Relay mode: plan]',
@@ -94,6 +99,8 @@ function buildPromptPrefixPatterns(mode) {
94
99
  return [
95
100
  new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
96
101
  new RegExp(`^${whitespaceFlexiblePattern(legacyFullPrefix)}\\s*`, 'i'),
102
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
103
+ new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
97
104
  new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
98
105
  ];
99
106
  });
@@ -0,0 +1,36 @@
1
+ // The note the relay writes into a conversation whose turn a relay restart
2
+ // cut off ("After a relay restart, tell interrupted sessions to carry on",
3
+ // server/services/restart-resume-service.mjs): a compact row in the look of
4
+ // the relay event rows, never a bubble of the user's.
5
+ //
6
+ // DOM-free, like relay-event-view.mjs: the caller owns the element.
7
+
8
+ export const RESTART_RESUME_KIND = 'relay-restart-resume';
9
+ export const RESTART_RESUME_LEAD = 'Automatic message from OAR, not from the user:';
10
+
11
+ function defaultEscape(value) {
12
+ return String(value ?? '')
13
+ .replace(/&/g, '&')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;')
16
+ .replace(/"/g, '&quot;')
17
+ .replace(/'/g, '&#39;');
18
+ }
19
+
20
+ export function isRestartResumeNote(message) {
21
+ return message?.role === 'user' && String(message?.kind || '').trim() === RESTART_RESUME_KIND;
22
+ }
23
+
24
+ /** The row's markup: folded to one line, opened to the note as the agent got it. */
25
+ export function renderRestartResumeNoteHtml(message, { escHtml = defaultEscape, timeText = '' } = {}) {
26
+ const text = String(message?.text ?? '').trim();
27
+ const body = text.startsWith(RESTART_RESUME_LEAD) ? text.slice(RESTART_RESUME_LEAD.length).trim() : text;
28
+ return '<details class="relay-event-row relay-restart-note">'
29
+ + '<summary class="relay-event-summary">'
30
+ + '<span class="relay-event-tag" title="An automated message of the relay, not from you.">relay</span>'
31
+ + '<span class="relay-event-text">Relay restarted · the interrupted turn was asked to carry on</span>'
32
+ + (timeText ? `<span class="relay-event-time">${escHtml(timeText)}</span>` : '')
33
+ + '</summary>'
34
+ + `<div class="relay-event-lines relay-restart-note-text">${escHtml(body)}</div>`
35
+ + '</details>';
36
+ }