@oar-sh/oar 0.9.12 → 0.9.13

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 (62) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/README.md +186 -11
  5. package/server/claude-worker/claude-session-process.mjs +53 -2
  6. package/server/claude-worker/task-output-discovery.mjs +28 -2
  7. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
  9. package/server/db-schema.mjs +10 -0
  10. package/server/grok-worker/acp-host-services.mjs +6 -3
  11. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  12. package/server/migrations/0018-background-task-rows.mjs +101 -0
  13. package/server/public/app/action-confirmations.js +12 -15
  14. package/server/public/app/api-client.js +57 -3
  15. package/server/public/app/background-tasks-view.mjs +56 -7
  16. package/server/public/app/bootstrap.js +7 -0
  17. package/server/public/app/conversation-preferences.mjs +10 -4
  18. package/server/public/app/conversation-view.js +33 -9
  19. package/server/public/app/host-suspend-ui.js +45 -44
  20. package/server/public/app/journal-view.js +9 -3
  21. package/server/public/app/push-settings.js +1 -0
  22. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  23. package/server/public/app/relay-restart-note-view.mjs +36 -0
  24. package/server/public/app/relay-restart-ui.js +568 -0
  25. package/server/public/app/settings-modal.js +52 -0
  26. package/server/public/app/socket-handlers.js +1 -1
  27. package/server/public/app/task-console-render.mjs +171 -7
  28. package/server/public/app/task-console.mjs +111 -11
  29. package/server/public/app/task-output-text.mjs +15 -6
  30. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  31. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  32. package/server/public/index.html +67 -0
  33. package/server/relay-tools.md +5 -0
  34. package/server/repositories/message-repository.mjs +3 -0
  35. package/server/repositories/remote-relay-repository.mjs +4 -2
  36. package/server/routes/messages-routes.mjs +155 -27
  37. package/server/routes/relay-board-routes.mjs +19 -2
  38. package/server/routes/relay-restart-routes.mjs +73 -0
  39. package/server/routes/sessions-routes.mjs +75 -3
  40. package/server/server-runtime.mjs +192 -137
  41. package/server/services/about-service.mjs +13 -12
  42. package/server/services/background-task-persistence.mjs +133 -0
  43. package/server/services/background-task-store.mjs +240 -13
  44. package/server/services/conversation-model-fallback.mjs +57 -0
  45. package/server/services/host-suspend-command.mjs +2 -1
  46. package/server/services/preview-proxy-server.mjs +14 -1
  47. package/server/services/process-list-service.mjs +2 -1
  48. package/server/services/provider-model-selection.mjs +195 -0
  49. package/server/services/push-dispatch-service.mjs +9 -2
  50. package/server/services/relay-event-service.mjs +27 -9
  51. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  52. package/server/services/relay-restart-picture.mjs +209 -0
  53. package/server/services/relay-restart-queue.mjs +380 -0
  54. package/server/services/relay-shutdown-gate.mjs +4 -1
  55. package/server/services/remote-relay-dispatcher.mjs +187 -19
  56. package/server/services/restart-resume-service.mjs +303 -0
  57. package/server/services/session-worker-process-service.mjs +6 -4
  58. package/server/services/task-output-service.mjs +217 -18
  59. package/shared/relay-events.mjs +55 -2
  60. package/shared/remote-relay-contract.mjs +15 -8
  61. package/shared/task-output-text.mjs +196 -83
  62. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -12,13 +12,16 @@ import { apiFetch } from './api-client.js';
12
12
  import { formatTaskModelLabel, formatTaskTokens } from './task-row-format.mjs';
13
13
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
14
14
  import {
15
+ TASK_OUTPUT_NONE_MESSAGES,
15
16
  endedOutcome,
16
17
  normalizeStallThreshold,
18
+ pipeHoldNotice,
17
19
  runnerChip,
18
20
  stallHint,
19
21
  stepsLabel,
20
22
  taskHasConsole,
21
23
  taskOutputUnavailable,
24
+ taskPipeHold,
22
25
  } from './task-console-render.mjs';
23
26
 
24
27
  // Shared with the sub-agent rows (subagent-row.mjs); re-exported for the
@@ -52,6 +55,12 @@ const stopsInFlight = new Set();
52
55
  const workflowFoldState = new Map();
53
56
  let currentConversationId = null;
54
57
  let elapsedTimer = null;
58
+ // Counts the `background_tasks` events per conversation. A reload's payload
59
+ // is the set as the relay had it when the request was answered; one that was
60
+ // asked for before the latest event can land after it and would put the
61
+ // older set back (the panel shrinking under the reader, then growing again
62
+ // with the next poll).
63
+ const taskEventCounts = new Map();
55
64
 
56
65
  const TASK_TYPE_ICONS = {
57
66
  local_bash: '⌨️',
@@ -363,14 +372,35 @@ function notifyChange(conversationId) {
363
372
  }
364
373
  }
365
374
 
375
+ /**
376
+ * Taken before a reload of the conversation is asked for, and handed to
377
+ * isBackgroundTasksSnapshotCurrent() with its answer.
378
+ */
379
+ export function backgroundTasksSnapshotMark(conversationId) {
380
+ return taskEventCounts.get(String(conversationId || '').trim()) || 0;
381
+ }
382
+
383
+ /**
384
+ * Whether a reload's sets (`backgroundTasks`, `endedBackgroundTasks`) may
385
+ * replace the conversation's on the page: no `background_tasks` event for it
386
+ * arrived since `mark` was taken. Otherwise the event's set is at least as
387
+ * new, and the next reload brings whatever came after it. Without a mark:
388
+ * always.
389
+ */
390
+ export function isBackgroundTasksSnapshotCurrent(conversationId, mark) {
391
+ return mark === undefined || mark === null || mark === backgroundTasksSnapshotMark(conversationId);
392
+ }
393
+
366
394
  /**
367
395
  * The live set (REPLACE semantics). `endedTasks`, when the event or payload
368
396
  * carries it, replaces the ended set too; an older relay's event without it
369
- * leaves the ended rows alone.
397
+ * leaves the ended rows alone. `event`: it came as a `background_tasks`
398
+ * event (see isBackgroundTasksSnapshotCurrent).
370
399
  */
371
- export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks } = {}) {
400
+ export function setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event = false } = {}) {
372
401
  const id = String(conversationId || '').trim();
373
402
  if (!id) return;
403
+ if (event) taskEventCounts.set(id, backgroundTasksSnapshotMark(id) + 1);
374
404
  const normalized = normalizeTaskList(tasks);
375
405
  // Drop fold choices for tasks that left this conversation's set so the map
376
406
  // cannot grow across long sessions.
@@ -460,6 +490,20 @@ export function findBackgroundTask(conversationId, taskId) {
460
490
  return ended ? { task: ended, ended: true } : null;
461
491
  }
462
492
 
493
+ const panelRenderListeners = new Set();
494
+
495
+ /** Called after the panel drew its rows (the task console keeps above its row). */
496
+ export function onBackgroundTasksPanelRendered(listener) {
497
+ panelRenderListeners.add(listener);
498
+ return () => panelRenderListeners.delete(listener);
499
+ }
500
+
501
+ function notifyPanelRendered() {
502
+ for (const listener of panelRenderListeners) {
503
+ try { listener(); } catch (error) { console.warn('background task panel listener failed', error?.message || error); }
504
+ }
505
+ }
506
+
463
507
  /** Called with the conversation id after any change of tasks or health. */
464
508
  export function subscribeBackgroundTasks(listener) {
465
509
  changeListeners.add(listener);
@@ -538,7 +582,7 @@ function taskTitleHtml(task) {
538
582
  return `<button type="button" class="subagent-open bg-task-desc" data-subagent-open="${escHtml(runId)}" title="Open this sub-agent's thoughts, tool calls and reply">${text}</button>`;
539
583
  }
540
584
  if (taskOutputUnavailable(task)) {
541
- return `<span class="bg-task-desc bg-task-desc-no-output" title="output not available for this provider">${text}</span>`;
585
+ return `<span class="bg-task-desc bg-task-desc-no-output" title="${escHtml(TASK_OUTPUT_NONE_MESSAGES.provider)}">${text}</span>`;
542
586
  }
543
587
  if (taskHasConsole(task)) {
544
588
  return `<button type="button" class="task-console-open bg-task-desc" data-task-console="${escHtml(task.taskId)}" title="Open this task's output">${text}</button>`;
@@ -553,10 +597,13 @@ function chipHtml(health) {
553
597
  }
554
598
 
555
599
  // Always present on a live row (hidden while output is fresh) so the 1 s
556
- // timer can update it in place.
557
- function stallHtml(hint) {
600
+ // timer can update it in place. A command piped into tail is quiet
601
+ // until it ends: its hover title says so.
602
+ function stallHtml(hint, task) {
558
603
  const classes = `bg-task-stall${hint?.warn ? ' bg-task-stall-warn' : ''}`;
559
- return `<span class="${classes}"${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
604
+ const notice = pipeHoldNotice(taskPipeHold(task));
605
+ const title = notice ? ` title="${escHtml(notice.title)}"` : '';
606
+ return `<span class="${classes}"${title}${hint ? '' : ' hidden'}>${hint ? escHtml(hint.text) : ''}</span>`;
560
607
  }
561
608
 
562
609
  function taskSteps(task) {
@@ -613,7 +660,7 @@ export function renderTaskRow(conversationId, task) {
613
660
  ${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
614
661
  ${steps ? `<span class="bg-task-steps">${escHtml(steps)}</span>` : ''}
615
662
  ${chipHtml(health)}
616
- ${taskHasConsole(task) ? stallHtml(stall) : ''}
663
+ ${taskHasConsole(task) ? stallHtml(stall, task) : ''}
617
664
  <span class="bg-task-elapsed">${escHtml(elapsed)}</span>
618
665
  ${task.stoppable === false ? '' : `<button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>`}
619
666
  </span>
@@ -684,6 +731,7 @@ export function renderBackgroundTasksPanel() {
684
731
  const staleList = panel.querySelector('#background-tasks-list');
685
732
  if (staleList) staleList.innerHTML = '';
686
733
  stopElapsedTimer();
734
+ notifyPanelRendered();
687
735
  return;
688
736
  }
689
737
  const wasOpen = panel.hasAttribute('open');
@@ -733,6 +781,7 @@ export function renderBackgroundTasksPanel() {
733
781
  if (wasOpen) panel.setAttribute('open', '');
734
782
  if (tasks.length) startElapsedTimer();
735
783
  else stopElapsedTimer();
784
+ notifyPanelRendered();
736
785
  }
737
786
 
738
787
  // A preview created, closed, or flipping online/offline re-renders the panel;
@@ -149,6 +149,7 @@ import {
149
149
  canMergeIntoTranscript,
150
150
  } from './conversation-view.js';
151
151
  import { loadKeepingLiveRows } from './transcript-reload-freshness.mjs';
152
+ import { backgroundTasksSnapshotMark } from './background-tasks-view.mjs';
152
153
  import { newerPageReachesEnd } from './transcript-island.mjs';
153
154
  import { initPinnedMessagesView, setPinsConversation } from './pinned-messages-view.mjs';
154
155
  import { initHiddenMessagesView, setHiddenMessagesConversation } from './hidden-messages-view.mjs';
@@ -339,6 +340,7 @@ import {
339
340
  updateBackgroundTaskTimeoutSetting,
340
341
  previewTaskStallMinutesSetting,
341
342
  updateTaskStallMinutesSetting,
343
+ updateRestartResumeSettingFromToggle,
342
344
  updateSubagentRecordRetentionSetting,
343
345
  } from './settings-modal.js';
344
346
  import {
@@ -3758,6 +3760,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3758
3760
  const savedScrollTop = messagesEl?.scrollTop || 0;
3759
3761
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
3760
3762
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
3763
+ const tasksMark = backgroundTasksSnapshotMark(currentId);
3761
3764
  const jumpEpoch = getTranscriptJumpEpoch();
3762
3765
  const { response, stale } = await loadKeepingLiveRows(
3763
3766
  () => loadConversation(currentId, { limit: requestLimit }),
@@ -3783,6 +3786,7 @@ async function pollAuthenticatedCurrentConversationLive() {
3783
3786
  savedScrollTop: preserveBottom ? null : stableSavedScrollTop,
3784
3787
  followLiveUpdates: preserveBottom,
3785
3788
  mergeFrom: merge?.mergeFrom || null,
3789
+ backgroundTasksAsOf: tasksMark,
3786
3790
  });
3787
3791
  // A merge does not scroll as the full render does: a reader at the end
3788
3792
  // is taken to the new end here.
@@ -4061,6 +4065,7 @@ async function refreshCurrentView() {
4061
4065
 
4062
4066
  const savedLoadedCount = loadConversationLoadedMessageCount(currentId);
4063
4067
  const requestLimit = Math.max(20, getConversationLoadedMessageCount() || 0, savedLoadedCount || 0);
4068
+ const tasksMark = backgroundTasksSnapshotMark(currentId);
4064
4069
  const { response: r, stale } = await loadKeepingLiveRows(
4065
4070
  () => loadConversation(currentId, { limit: requestLimit }),
4066
4071
  getTranscriptLiveRowEpoch,
@@ -4085,6 +4090,7 @@ async function refreshCurrentView() {
4085
4090
  followLiveUpdates: preserveBottom && !keepTranscript,
4086
4091
  keepTranscript,
4087
4092
  mergeFrom: merge?.mergeFrom || null,
4093
+ backgroundTasksAsOf: tasksMark,
4088
4094
  });
4089
4095
  // As in the live poll: a merge leaves the scrolling to the caller.
4090
4096
  if (merge && preserveBottom && messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight;
@@ -5102,6 +5108,7 @@ window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting
5102
5108
  window.updateBackgroundTaskTimeoutSetting = updateBackgroundTaskTimeoutSetting;
5103
5109
  window.previewTaskStallMinutesSetting = previewTaskStallMinutesSetting;
5104
5110
  window.updateTaskStallMinutesSetting = updateTaskStallMinutesSetting;
5111
+ window.updateRestartResumeSettingFromToggle = updateRestartResumeSettingFromToggle;
5105
5112
  window.updateSubagentRecordRetentionSetting = updateSubagentRecordRetentionSetting;
5106
5113
  window.togglePushOnThisDevice = togglePushOnThisDevice;
5107
5114
  window.updatePushPreferencesFromControls = updatePushPreferencesFromControls;
@@ -89,11 +89,14 @@ function normalizeEffortList(efforts = []) {
89
89
  * 3. else the lowest supported rung ABOVE it, ignoring 'none'
90
90
  * (a remembered 'minimal' on a model whose floor is 'low' → low);
91
91
  * 4. else the next candidate.
92
- * With no candidate usable: the lowest non-'none' rung the model offers, or
93
- * 'none' when that is all there is. Effort names off the ladder (image
94
- * quality values, unknown providers) only ever match exactly.
92
+ * With no candidate usable: the lowest non-'none' rung the model offers (by
93
+ * rung, whatever the list order), or 'none' when that is all there is; a
94
+ * list with names off the ladder keeps its own order there. A caller with a
95
+ * default of its own passes `fallback`, returned instead in that case.
96
+ * Effort names off the ladder (image quality values, unknown providers) only
97
+ * ever match exactly.
95
98
  */
96
- export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = []) {
99
+ export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
97
100
  const options = normalizeEffortList(supportedEfforts);
98
101
  if (!options.length) return '';
99
102
  const rungOf = (effort) => REASONING_EFFORT_LADDER.indexOf(effort);
@@ -109,6 +112,9 @@ export function nearestSupportedReasoningEffort(candidates = [], supportedEffort
109
112
  const above = ranked.find((option) => rungOf(option) > rung && option !== 'ultracode');
110
113
  if (above) return above;
111
114
  }
115
+ if (fallback !== undefined) return fallback;
116
+ const lowest = ranked.find((option) => option !== 'ultracode');
117
+ if (lowest && options.every((option) => rungOf(option) >= 0)) return lowest;
112
118
  return options.find((option) => option !== 'none') || options[0];
113
119
  }
114
120
 
@@ -67,6 +67,7 @@ import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomp
67
67
  import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
68
68
  import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
69
69
  import { isRelayEventMessage, renderRelayEventRowHtml } from './relay-event-view.mjs';
70
+ import { isRestartResumeNote, renderRestartResumeNoteHtml } from './relay-restart-note-view.mjs';
70
71
  import {
71
72
  AGENT_EXCHANGE_CLOSED_CLASS,
72
73
  AGENT_EXCHANGE_HEAD_CLASS,
@@ -398,14 +399,18 @@ const conversationHistoryLoader = createInfiniteLoader({
398
399
  syncSteeredTurnMerge(el);
399
400
  // The page's agent exchanges fold before the height delta is measured.
400
401
  syncAgentExchangeFolds(el);
402
+ // Here, not a frame later: the position taken above is the reader's only
403
+ // until they scroll again, and a scroll in that frame (a jump to the end
404
+ // and up again) was undone by it.
405
+ el.scrollTop = previousScrollTop + (el.scrollHeight - previousScrollHeight);
406
+ // The page's images have no height yet: the place read is held while
407
+ // they come in, as after a restore.
408
+ const place = captureConversationReadingAnchor();
409
+ if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
410
+ // Once the loader has taken this page's cursor: still near the top, the
411
+ // next page loads.
401
412
  requestAnimationFrame(() => {
402
- if (!el || String(currentConvId || '').trim() !== currentId) return;
403
- const nextScrollHeight = el.scrollHeight;
404
- el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight);
405
- // The page's images have no height yet: the place read is held while
406
- // they come in, as after a restore.
407
- const place = captureConversationReadingAnchor();
408
- if (place?.conversationId === currentId) holdReadingAnchorWhileMediaLoads(el, place.anchor);
413
+ if (String(currentConvId || '').trim() !== currentId) return;
409
414
  void conversationHistoryLoader.handleBoundaryDistance(el.scrollTop);
410
415
  });
411
416
  },
@@ -1336,6 +1341,21 @@ function createRelayEventNode(msg, msgId) {
1336
1341
  return div;
1337
1342
  }
1338
1343
 
1344
+ // The relay's note to carry on after a restart cut a turn off: a folded row
1345
+ // like an event row (relay-restart-note-view.mjs).
1346
+ function createRestartResumeNoteNode(msg, msgId) {
1347
+ const div = document.createElement('div');
1348
+ div.className = 'msg relay-event relay-restart-resume';
1349
+ if (msgId) div.dataset.messageId = msgId;
1350
+ const fingerprint = buildLiveMessageFingerprint({ ...msg, id: msgId || msg?.id || '' });
1351
+ div.dataset.messageRole = fingerprint.role || '';
1352
+ div.dataset.messageTextFingerprint = fingerprint.text || '';
1353
+ div.dataset.messageTimestamp = String(msg?.timestamp || '').trim();
1354
+ div.innerHTML = renderRestartResumeNoteHtml(msg, { escHtml, timeText: msg?.timestamp ? fmtDate(msg.timestamp) : '' });
1355
+ setAgentExchangeRow(div, msg, msgId);
1356
+ return div;
1357
+ }
1358
+
1339
1359
  // Agent exchanges (agent-exchange-fold-view.mjs): an agent's prompt folds
1340
1360
  // into one row; this session's reply to it always shows. What the fold pass
1341
1361
  // needs of a message is kept on its node when the row is built, so the pass
@@ -1469,6 +1489,7 @@ function createMessageNode(msg, msgId = null, force = false) {
1469
1489
  // An event message the relay wrote into a watcher's conversation: a
1470
1490
  // compact, foldable row, never a bubble of the user's.
1471
1491
  if (isRelayEventMessage(msg)) return createRelayEventNode(msg, msgId);
1492
+ if (isRestartResumeNote(msg)) return createRestartResumeNoteNode(msg, msgId);
1472
1493
 
1473
1494
  // A prompt another relay's agent sent carries `origin`: its header line is
1474
1495
  // hidden (the badge below says the same), and a stray remote-relay mention
@@ -4602,7 +4623,10 @@ export async function sendMessage() {
4602
4623
  : undefined,
4603
4624
  };
4604
4625
 
4605
- const r = await sendMessageApi(body);
4626
+ // A refusal the user can act on (the conversation's model left the
4627
+ // catalog) is shown as the relay words it.
4628
+ let refusal = '';
4629
+ const r = await sendMessageApi(body, { onRefused: (reason) => { refusal = reason; } });
4606
4630
  if (!r) {
4607
4631
  // Offline: park the send in the durable outbox instead of bouncing it
4608
4632
  // back into the composer. The client-generated messageId makes a replay
@@ -4636,7 +4660,7 @@ export async function sendMessage() {
4636
4660
  sendLanded,
4637
4661
  });
4638
4662
  if (!mobileSend && viewingSendConversation()) input.focus();
4639
- setModelBanner('⚠️ Message could not be sent. Please try again.');
4663
+ setModelBanner(refusal ? `⚠️ ${refusal}` : '⚠️ Message could not be sent. Please try again.');
4640
4664
  return;
4641
4665
  }
4642
4666
 
@@ -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;
@@ -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, '&amp;')
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
+ }