@oar-sh/oar 0.9.3 → 0.9.5

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 (76) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -45,6 +45,8 @@ import {
45
45
  imageEditTarget,
46
46
  setImageEditTarget as setStoredImageEditTarget,
47
47
  getSessionWorkerState,
48
+ openSummaryModal,
49
+ closeSummaryModal,
48
50
  } from './store.js';
49
51
  import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
50
52
  import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
@@ -55,6 +57,9 @@ import { buildWorkflowRunCard } from './background-tasks-view.mjs';
55
57
  import { parsePreviewCommand, runPreviewCommand } from './preview-command.mjs';
56
58
  import { buildTranscriptPreviewCard } from './preview-cards.mjs';
57
59
  import { closeSlashAutocomplete, handleSlashAutocompleteKey, updateSlashAutocomplete } from './slash-autocomplete.mjs';
60
+ import { closeMentionAutocomplete, handleMentionAutocompleteKey, updateMentionAutocomplete } from './mention-autocomplete.mjs';
61
+ import { renderRemoteOriginBadgeHtml } from './remote-relay-origin-view.mjs';
62
+ import { stripRemoteRelayDisplayArtifacts } from './relay-prompt-sanitizer.mjs';
58
63
  import { evaluateUnknownCommandGuard } from './slash-commands.mjs';
59
64
  import { attachCodeCopyButtons } from './code-copy.mjs';
60
65
  import { relayErrorCtaActions } from './relay-error-ctas.mjs';
@@ -102,6 +107,16 @@ import {
102
107
  shouldApplyIncomingDraftToComposer,
103
108
  } from './conversation-draft-sync.mjs';
104
109
  import { isChatInteractionHeld, selectionIntersectsNode } from './selection-guard.mjs';
110
+ import {
111
+ applyThinkingFollow,
112
+ getThinkingFollowMode,
113
+ initThinkingFollow,
114
+ observeThinkingBubble,
115
+ renderThinkingFollowButtonsHtml,
116
+ syncThinkingFollowButtons,
117
+ toggleThinkingFollowMode,
118
+ unobserveThinkingBubble,
119
+ } from './thinking-follow.js';
105
120
  import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
106
121
  import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
107
122
  import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
@@ -1212,8 +1227,18 @@ function createMessageNode(msg, msgId = null, force = false) {
1212
1227
  seenMessageIds.add(msgId);
1213
1228
  }
1214
1229
 
1230
+ // A prompt another relay's agent sent carries `origin`: its header line is
1231
+ // hidden (the badge below says the same), and a stray remote-relay mention
1232
+ // hint never shows on any user bubble.
1233
+ const remoteOrigin = (msg?.role === 'user' && msg?.origin && typeof msg.origin === 'object') ? msg.origin : null;
1234
+ if (msg?.role === 'user') {
1235
+ const displayText = stripRemoteRelayDisplayArtifacts(msg.text, { origin: remoteOrigin });
1236
+ if (displayText !== String(msg.text ?? '')) msg = { ...msg, text: displayText };
1237
+ }
1238
+
1215
1239
  const div = document.createElement('div');
1216
1240
  div.className = `msg ${msg.role}`;
1241
+ if (remoteOrigin) div.classList.add('msg-from-remote-relay');
1217
1242
  if (msgId) div.dataset.messageId = msgId;
1218
1243
  const fingerprint = buildLiveMessageFingerprint({
1219
1244
  ...(msg && typeof msg === 'object' ? msg : {}),
@@ -1252,6 +1277,8 @@ function createMessageNode(msg, msgId = null, force = false) {
1252
1277
  // The settle marker of a steer the CLI consumed without a turn of its own:
1253
1278
  // folded into (and answered by) the turn above, or cut off unanswered by a
1254
1279
  // Stop. Compact, attached to its own user message, never merging forward.
1280
+ // Also the marker of a message answered on a continuation row, which only
1281
+ // shares the look.
1255
1282
  const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
1256
1283
  if (steerMarkerClass) div.classList.add(steerMarkerClass);
1257
1284
  const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
@@ -1376,8 +1403,13 @@ function createMessageNode(msg, msgId = null, force = false) {
1376
1403
  ? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn" data-action="resend-stopped-steer" data-message-id="${escHtml(msgId)}" title="Send this message again as a new turn"${resendState ? ' disabled' : ''}>${resendLabel}</button></div>`
1377
1404
  : '';
1378
1405
 
1406
+ // Shared viewers get the badge without the link to the other relay.
1407
+ const originBadgeHtml = remoteOrigin
1408
+ ? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW })
1409
+ : '';
1410
+
1379
1411
  div.innerHTML = `
1380
- <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
1412
+ <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
1381
1413
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
1382
1414
 
1383
1415
  const bubble = div.querySelector('.msg-bubble');
@@ -1699,10 +1731,14 @@ export function showThinking(messageId = null, autoScroll = true) {
1699
1731
  stopBtn.classList.toggle('stopping', stopping);
1700
1732
  }
1701
1733
  anchorThinkingBubble(existing, nextMessageId || existing.dataset.messageId);
1702
- if (autoScroll) scrollBottom();
1734
+ syncThinkingFollowButtons(existing, getThinkingFollowMode());
1735
+ observeThinkingBubble(existing);
1736
+ if (getThinkingFollowMode()) applyThinkingFollow();
1737
+ else if (autoScroll) scrollBottom();
1703
1738
  return;
1704
1739
  }
1705
1740
  existing?.remove();
1741
+ unobserveThinkingBubble();
1706
1742
  const div = document.createElement('div');
1707
1743
  div.className = 'msg assistant';
1708
1744
  div.id = 'thinking-indicator';
@@ -1712,9 +1748,12 @@ export function showThinking(messageId = null, autoScroll = true) {
1712
1748
  const stopBtnHtml = (!IS_SHARED_VIEW && nextMessageId)
1713
1749
  ? `<button type="button" class="bubble-action-btn${isCancelInFlight ? ' stopping' : ''}" data-action="stop-turn" data-message-id="${escHtml(nextMessageId)}"${isCancelInFlight ? ' disabled' : ''}>${isCancelInFlight ? 'Stopping…' : 'Stop'}</button>`
1714
1750
  : '';
1751
+ // Stop stays left; the follow buttons (💭 thoughts, 📃 answer, 🛠️ tools)
1752
+ // sit on the right. See thinking-follow.js.
1753
+ const followBtnsHtml = renderThinkingFollowButtonsHtml(getThinkingFollowMode());
1715
1754
  div.innerHTML = `
1716
1755
  <div class="thinking-bubble">
1717
- <div class="thinking-bubble-header">${stopBtnHtml}</div>
1756
+ <div class="thinking-bubble-header">${stopBtnHtml}${followBtnsHtml}</div>
1718
1757
  <details id="thinking-thoughts" class="thinking-thoughts-panel" open>
1719
1758
  <summary>💭 Thoughts</summary>
1720
1759
  <div class="thinking-thoughts-list"></div>
@@ -1727,15 +1766,49 @@ export function showThinking(messageId = null, autoScroll = true) {
1727
1766
  anchorThinkingBubble(div, nextMessageId);
1728
1767
  renderThinkingThoughts();
1729
1768
  renderThinkingStream();
1730
- if (autoScroll) scrollBottom();
1769
+ observeThinkingBubble(div);
1770
+ if (getThinkingFollowMode()) applyThinkingFollow();
1771
+ else if (autoScroll) scrollBottom();
1731
1772
  }
1732
1773
 
1733
1774
  export function removeThinking() {
1734
1775
  thinkingMessageId = null;
1735
1776
  lastInFlightSnapshotKey = '';
1777
+ unobserveThinkingBubble();
1736
1778
  document.getElementById('thinking-indicator')?.remove();
1737
1779
  }
1738
1780
 
1781
+ // Stop is one tap away on a sticky header, so a stray touch must not kill a
1782
+ // long turn: confirm first, then run the existing stop path.
1783
+ function openStopTurnConfirmation(conversationId, messageId) {
1784
+ const convId = String(conversationId || '').trim();
1785
+ const id = String(messageId || '').trim();
1786
+ if (!convId || !id) return;
1787
+ if (bubbleCancelInFlight.has(id)) return;
1788
+ openSummaryModal({
1789
+ title: 'Stop this turn?',
1790
+ subtitle: 'The agent stops after its current step',
1791
+ kind: 'stop-turn',
1792
+ bodyHtml: `
1793
+ <p>Stop the running turn? Work already done stays; anything the agent was about to do is cut short.</p>
1794
+ <div class="summary-modal-actions">
1795
+ <button class="chat-title-action-btn danger-btn" type="button" data-stop-turn-confirm="1">⏹ Stop turn</button>
1796
+ <button class="chat-title-action-btn" type="button" data-stop-turn-cancel="1">Keep running</button>
1797
+ </div>
1798
+ `,
1799
+ });
1800
+ const body = document.getElementById('summary-modal-body');
1801
+ body?.querySelector('[data-stop-turn-confirm]')?.addEventListener('click', (event) => {
1802
+ event.preventDefault();
1803
+ closeSummaryModal();
1804
+ void stopTurnByMessageId(convId, id);
1805
+ }, { once: true });
1806
+ body?.querySelector('[data-stop-turn-cancel]')?.addEventListener('click', (event) => {
1807
+ event.preventDefault();
1808
+ closeSummaryModal();
1809
+ }, { once: true });
1810
+ }
1811
+
1739
1812
  export function collapseThinkingThoughts() {
1740
1813
  const panel = document.getElementById('thinking-thoughts');
1741
1814
  if (!(panel instanceof HTMLDetailsElement)) return;
@@ -2919,7 +2992,7 @@ function runRelayErrorCta(cta) {
2919
2992
  }
2920
2993
 
2921
2994
  function handleBubbleActionClick(event) {
2922
- const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn');
2995
+ const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn');
2923
2996
  if (!btn) return;
2924
2997
  const action = btn.dataset.action;
2925
2998
  const messageId = btn.dataset.messageId;
@@ -2944,7 +3017,15 @@ function handleBubbleActionClick(event) {
2944
3017
  if (action === 'stop-turn' && messageId) {
2945
3018
  event.preventDefault();
2946
3019
  event.stopPropagation();
2947
- void stopTurnByMessageId(currentConvId, messageId);
3020
+ openStopTurnConfirmation(currentConvId, messageId);
3021
+ }
3022
+
3023
+ if (action === 'follow-thinking') {
3024
+ event.preventDefault();
3025
+ event.stopPropagation();
3026
+ const mode = toggleThinkingFollowMode(currentConvId, btn.dataset.follow);
3027
+ syncThinkingFollowButtons(document, mode);
3028
+ if (mode) applyThinkingFollow({ force: true });
2948
3029
  }
2949
3030
 
2950
3031
  if (action === 'cancel-queued' && messageId) {
@@ -2997,6 +3078,7 @@ export function initBubbleActionHandlers() {
2997
3078
  if (!messagesEl) return;
2998
3079
  messagesEl.addEventListener('click', handleBubbleActionClick);
2999
3080
  messagesEl.addEventListener('click', handleEmbeddedImageClick);
3081
+ initThinkingFollow({ getCurrentConversationId: () => currentConvId });
3000
3082
  }
3001
3083
 
3002
3084
  export function appendMessage(msg, scroll = true, msgId = null, force = false, insertAfterId = null, trackHistory = true) {
@@ -3342,6 +3424,7 @@ export async function sendMessage() {
3342
3424
  }
3343
3425
  if (!hasDraft) return;
3344
3426
  closeSlashAutocomplete();
3427
+ closeMentionAutocomplete();
3345
3428
 
3346
3429
  // Warn-once typo guard: a message that looks like a command but matches none
3347
3430
  // would otherwise burn an agent turn as plain text.
@@ -3589,6 +3672,12 @@ export async function sendMessage() {
3589
3672
  };
3590
3673
  window.syncAutoModelAvailability?.();
3591
3674
  }
3675
+ // Adopting the conversation can grow the composer (session-lock note,
3676
+ // warning banner), which shrinks the transcript under a pane that was
3677
+ // pinned to the newest message. Re-pin now rather than after the draft
3678
+ // save's round trip below, or the new bubble sits under the composer
3679
+ // for as long as that request takes.
3680
+ if (viewingSendConversation()) scrollBottom();
3592
3681
  const persistedConversationId = String(r.conversationId || targetConversationId || '').trim();
3593
3682
  if (persistedConversationId) {
3594
3683
  restorableDraftByConversation.delete(persistedConversationId);
@@ -3661,12 +3750,16 @@ export async function sendMessage() {
3661
3750
  // menu in sync with the caret. Cheap no-op for non-slash text.
3662
3751
  export function updateComposerSlashMenu(input) {
3663
3752
  updateSlashAutocomplete(input, { conversationId: currentConvId });
3753
+ // Second, so it sees the slash menu's verdict: the @relay popup never opens
3754
+ // over an open slash menu, and at most one of them ever handles keys.
3755
+ updateMentionAutocomplete(input);
3664
3756
  }
3665
3757
 
3666
3758
  export function handleKey(e) {
3667
3759
  // The open menu owns navigation keys; Ctrl/Cmd+Enter still falls through to
3668
- // send because the menu never consumes it.
3760
+ // send because neither menu consumes it.
3669
3761
  if (handleSlashAutocompleteKey(e, e.target)) return;
3762
+ if (handleMentionAutocompleteKey(e, e.target)) return;
3670
3763
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
3671
3764
  e.preventDefault();
3672
3765
  sendMessage();
@@ -0,0 +1,272 @@
1
+ // Pending host suspend / relay restart, as every device sees it: an app-wide
2
+ // banner with a countdown and Cancel, plus the chat-actions menu labels.
3
+ // State arrives over the socket (`host_suspend_state`, `relay_shutdown_state`)
4
+ // and is also re-read from GET /api/status polls, so a client that missed an
5
+ // event still catches up. The banner ignores the per-device "Show Suspend
6
+ // host action" setting on purpose: a device with the action hidden must still
7
+ // be able to see and cancel a queued suspend.
8
+
9
+ import { escHtml, showTransientRelayNotice } from './store.js';
10
+ import { cancelHostSuspend, cancelRelayRestart } from './api-client.js';
11
+
12
+ const BANNER_ID = 'pending-action-banner';
13
+ const SUSPEND_MENU_ID = 'chat-menu-suspend-host';
14
+ const RESTART_MENU_ID = 'chat-menu-restart-relay';
15
+ const SUSPEND_MENU_LABEL = '💤 Suspend host';
16
+ const SUSPEND_MENU_PENDING_LABEL = '💤 Suspend pending…';
17
+ const RESTART_MENU_LABEL = '🌄 Restart web relay';
18
+ const RESTART_MENU_PENDING_LABEL = '🌄 Restart pending…';
19
+
20
+ let hostSuspendState = null;
21
+ let relayShutdownState = null;
22
+ let countdownTimer = null;
23
+ let cancelInFlight = false;
24
+
25
+ export function getHostSuspendState() {
26
+ return hostSuspendState;
27
+ }
28
+
29
+ export function getRelayShutdownState() {
30
+ return relayShutdownState;
31
+ }
32
+
33
+ export function isHostSuspendPending(state = hostSuspendState) {
34
+ const status = String(state?.status || '').trim();
35
+ return status === 'queued' || status === 'countdown' || status === 'suspending';
36
+ }
37
+
38
+ export function isRelayShutdownPending(state = relayShutdownState) {
39
+ const status = String(state?.status || '').trim();
40
+ return status === 'queued' || status === 'shutting_down';
41
+ }
42
+
43
+ /** "0:27" style remaining time from a fireAt ISO string; null when not counting. */
44
+ export function formatCountdown(fireAt, nowMs = Date.now()) {
45
+ const target = Date.parse(String(fireAt || ''));
46
+ if (!Number.isFinite(target)) return null;
47
+ const remaining = Math.max(0, Math.ceil((target - nowMs) / 1000));
48
+ const minutes = Math.floor(remaining / 60);
49
+ const seconds = remaining % 60;
50
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
51
+ }
52
+
53
+ /** One human line per blocker, e.g. "sidebar polish — 2 background agents: …". */
54
+ export function describeHostSuspendBlocker(blocker) {
55
+ if (!blocker || typeof blocker !== 'object') return '';
56
+ const title = String(blocker.title || '').trim();
57
+ const detail = String(blocker.detail || '').trim();
58
+ switch (String(blocker.kind || '')) {
59
+ case 'turn':
60
+ return `${title || 'a session'} — ${detail || 'turn running'}`;
61
+ case 'background':
62
+ return `${title || 'a session'} — ${detail || 'background agents running'}`;
63
+ case 'ci':
64
+ return `CI ${title ? `in ${title}` : ''} — ${detail || 'runs open'}`.replace(/\s+—/, ' —');
65
+ case 'ci-unknown':
66
+ return detail || `CI state of ${title || 'a repository'} unknown`;
67
+ case 'ci-checking':
68
+ return detail || 'Checking CI state';
69
+ case 'error':
70
+ return detail || 'Activity check failed';
71
+ default:
72
+ return [title, detail].filter(Boolean).join(' — ');
73
+ }
74
+ }
75
+
76
+ /** Short banner summary: first two blockers, "+N more". */
77
+ export function summarizeHostSuspendBlockers(blockers) {
78
+ const list = (Array.isArray(blockers) ? blockers : []).map(describeHostSuspendBlocker).filter(Boolean);
79
+ if (!list.length) return '';
80
+ const shown = list.slice(0, 2);
81
+ const rest = list.length - shown.length;
82
+ return shown.join('; ') + (rest > 0 ? `; +${rest} more` : '');
83
+ }
84
+
85
+ export function hostSuspendBannerText(state, nowMs = Date.now()) {
86
+ if (!isHostSuspendPending(state)) return '';
87
+ const status = String(state.status || '');
88
+ if (status === 'suspending') return '💤 Suspending host now…';
89
+ if (status === 'countdown') {
90
+ const countdown = formatCountdown(state.fireAt, nowMs);
91
+ return countdown ? `💤 Everything is idle — suspending host in ${countdown}` : '💤 Everything is idle — suspending host shortly';
92
+ }
93
+ const summary = summarizeHostSuspendBlockers(state.blockers);
94
+ return summary
95
+ ? `💤 Suspend queued — waiting for: ${summary}`
96
+ : '💤 Suspend queued — waiting for running agents to finish';
97
+ }
98
+
99
+ export function relayShutdownBannerText(state) {
100
+ 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
+ const queue = state.queue || {};
107
+ const turns = Number(queue.pendingCount || 0) + Number(queue.processingCount || 0) + Number(queue.parkedCount || 0);
108
+ const waiting = turns > 0 ? `waiting for ${turns} turn${turns === 1 ? '' : 's'} to finish` : 'queue is idle, going down now';
109
+ return `${icon} Relay ${action} queued — ${waiting}`;
110
+ }
111
+
112
+ function ensureBanner() {
113
+ let banner = document.getElementById(BANNER_ID);
114
+ if (banner) return banner;
115
+ banner = document.createElement('div');
116
+ banner.id = BANNER_ID;
117
+ banner.setAttribute('role', 'status');
118
+ banner.setAttribute('aria-live', 'polite');
119
+ document.body?.appendChild(banner);
120
+ return banner;
121
+ }
122
+
123
+ function bannerRow({ text, cancelLabel, onCancel, key }) {
124
+ const row = document.createElement('div');
125
+ row.className = 'pending-action-row';
126
+ row.dataset.pending = key;
127
+ const label = document.createElement('span');
128
+ label.className = 'pending-action-text';
129
+ label.textContent = text;
130
+ row.appendChild(label);
131
+ if (cancelLabel && typeof onCancel === 'function') {
132
+ const btn = document.createElement('button');
133
+ btn.type = 'button';
134
+ btn.className = 'relay-toast-action pending-action-cancel';
135
+ btn.textContent = cancelLabel;
136
+ btn.disabled = cancelInFlight;
137
+ btn.addEventListener('click', (event) => {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ void onCancel();
141
+ });
142
+ row.appendChild(btn);
143
+ }
144
+ return row;
145
+ }
146
+
147
+ async function runCancelHostSuspend() {
148
+ if (cancelInFlight) return;
149
+ cancelInFlight = true;
150
+ renderPendingActionBanner();
151
+ try {
152
+ const result = await cancelHostSuspend();
153
+ if (!result?.ok) {
154
+ showTransientRelayNotice('Failed to cancel the queued suspend.', 5000);
155
+ return;
156
+ }
157
+ if (result.state) applyHostSuspendState(result.state);
158
+ showTransientRelayNotice(result.cancelled ? 'Queued host suspend cancelled.' : 'No host suspend was pending.', 4000);
159
+ } finally {
160
+ cancelInFlight = false;
161
+ renderPendingActionBanner();
162
+ }
163
+ }
164
+
165
+ async function runCancelRelayShutdown() {
166
+ if (cancelInFlight) return;
167
+ cancelInFlight = true;
168
+ renderPendingActionBanner();
169
+ try {
170
+ const result = await cancelRelayRestart();
171
+ if (!result?.ok) {
172
+ showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
173
+ return;
174
+ }
175
+ applyRelayShutdownState(result);
176
+ showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled.' : 'No relay restart was pending.', 4000);
177
+ } finally {
178
+ cancelInFlight = false;
179
+ renderPendingActionBanner();
180
+ }
181
+ }
182
+
183
+ function syncCountdownTimer() {
184
+ const needsTicker = isHostSuspendPending() && String(hostSuspendState?.status || '') === 'countdown' && hostSuspendState?.fireAt;
185
+ if (needsTicker && !countdownTimer) {
186
+ countdownTimer = setInterval(() => renderPendingActionBanner(), 1000);
187
+ } else if (!needsTicker && countdownTimer) {
188
+ clearInterval(countdownTimer);
189
+ countdownTimer = null;
190
+ }
191
+ }
192
+
193
+ export function renderPendingActionBanner() {
194
+ if (typeof document === 'undefined') return;
195
+ const rows = [];
196
+ if (isHostSuspendPending()) {
197
+ rows.push(bannerRow({
198
+ key: 'host-suspend',
199
+ text: hostSuspendBannerText(hostSuspendState),
200
+ cancelLabel: String(hostSuspendState?.status || '') === 'suspending' ? '' : 'Cancel',
201
+ onCancel: runCancelHostSuspend,
202
+ }));
203
+ }
204
+ if (isRelayShutdownPending()) {
205
+ const restarting = String(relayShutdownState?.status || '') === 'shutting_down';
206
+ rows.push(bannerRow({
207
+ key: 'relay-shutdown',
208
+ text: relayShutdownBannerText(relayShutdownState),
209
+ cancelLabel: restarting ? '' : 'Cancel',
210
+ onCancel: runCancelRelayShutdown,
211
+ }));
212
+ }
213
+ const banner = document.getElementById(BANNER_ID) || (rows.length ? ensureBanner() : null);
214
+ if (!banner) return;
215
+ banner.replaceChildren(...rows);
216
+ banner.classList.toggle('visible', rows.length > 0);
217
+ banner.hidden = rows.length === 0;
218
+ syncMenuLabels();
219
+ syncCountdownTimer();
220
+ }
221
+
222
+ function syncMenuLabels() {
223
+ const suspendBtn = document.getElementById(SUSPEND_MENU_ID);
224
+ if (suspendBtn) suspendBtn.textContent = isHostSuspendPending() ? SUSPEND_MENU_PENDING_LABEL : SUSPEND_MENU_LABEL;
225
+ const restartBtn = document.getElementById(RESTART_MENU_ID);
226
+ if (restartBtn) restartBtn.textContent = isRelayShutdownPending() ? RESTART_MENU_PENDING_LABEL : RESTART_MENU_LABEL;
227
+ }
228
+
229
+ export function applyHostSuspendState(state) {
230
+ const previous = hostSuspendState;
231
+ hostSuspendState = state && typeof state === 'object' ? state : null;
232
+ if (isHostSuspendPending(previous) && !isHostSuspendPending(hostSuspendState) && hostSuspendState?.lastError) {
233
+ showTransientRelayNotice(`Host suspend failed: ${hostSuspendState.lastError}`, 8000);
234
+ }
235
+ renderPendingActionBanner();
236
+ }
237
+
238
+ export function applyRelayShutdownState(state) {
239
+ relayShutdownState = state && typeof state === 'object' ? state : null;
240
+ renderPendingActionBanner();
241
+ }
242
+
243
+ /** GET /api/status carries both states; the poll keeps late clients honest. */
244
+ export function applyPendingActionsFromStatus(status) {
245
+ if (!status || typeof status !== 'object') return;
246
+ if ('hostSuspend' in status) hostSuspendState = status.hostSuspend || null;
247
+ if ('relayShutdown' in status) {
248
+ const next = status.relayShutdown ? { ...status.relayShutdown } : null;
249
+ if (next && !next.queue) {
250
+ next.queue = {
251
+ pendingCount: Number(status.pendingCount || 0),
252
+ processingCount: Number(status.processingCount || 0),
253
+ parkedCount: Number(status.parkedCount || 0),
254
+ };
255
+ }
256
+ relayShutdownState = next;
257
+ }
258
+ renderPendingActionBanner();
259
+ }
260
+
261
+ export function initHostSuspendUi() {
262
+ renderPendingActionBanner();
263
+ }
264
+
265
+ export function __resetHostSuspendUiForTests() {
266
+ hostSuspendState = null;
267
+ relayShutdownState = null;
268
+ cancelInFlight = false;
269
+ if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
270
+ document.getElementById(BANNER_ID)?.remove();
271
+ syncMenuLabels();
272
+ }
@@ -58,6 +58,7 @@ import {
58
58
  conversationProviderIndicatorLabel,
59
59
  } from './conversation-provider-indicator.mjs';
60
60
  import { leaveStatusView } from './status-view.mjs';
61
+ import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
61
62
  import {
62
63
  normalizeConversationFilter,
63
64
  filterConversations,
@@ -349,10 +350,12 @@ export function renderConvList() {
349
350
  const providerIndicatorHtml = providerIndicatorLabel
350
351
  ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
351
352
  : '';
353
+ // "↗ win-test": another relay's agent created this conversation.
354
+ const originMarkerHtml = renderConversationOriginMarkerHtml(c);
352
355
  return `
353
356
  <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
354
357
  <div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${c.archived ? ' <span style="font-size:0.68rem;color:var(--muted)">(archived)</span>' : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
355
- <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${providerIndicatorHtml}</div>
358
+ <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
356
359
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
357
360
  </div>`;
358
361
  }).join('')}${footerHtml}`;