@oar-sh/oar 0.9.6 → 0.9.8

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 (105) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/README.md +222 -40
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/README.md +174 -7
  6. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  10. package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
  11. package/server/claude-worker/claude-session-process.mjs +225 -24
  12. package/server/claude-worker/claude-session-worker.mjs +6 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  14. package/server/db-schema.mjs +17 -0
  15. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  16. package/server/migrations/0007-claude-cloud.mjs +82 -0
  17. package/server/public/app/activity-replay-state.mjs +68 -5
  18. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  19. package/server/public/app/agent-sessions-settings.mjs +134 -0
  20. package/server/public/app/api-client.js +149 -6
  21. package/server/public/app/attachments-view.js +20 -1
  22. package/server/public/app/attribution-control.mjs +46 -0
  23. package/server/public/app/bootstrap.js +468 -88
  24. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  25. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  26. package/server/public/app/claude-cloud-ui.mjs +611 -0
  27. package/server/public/app/composer-control-state.mjs +11 -0
  28. package/server/public/app/context-menu.mjs +140 -0
  29. package/server/public/app/context-tier-options.mjs +4 -0
  30. package/server/public/app/context-usage-view.mjs +127 -8
  31. package/server/public/app/conversation-origin-ui.js +27 -0
  32. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  33. package/server/public/app/conversation-view.js +145 -11
  34. package/server/public/app/cwd-picker.js +12 -0
  35. package/server/public/app/journal-view.js +698 -28
  36. package/server/public/app/math-dollars.mjs +89 -0
  37. package/server/public/app/model-selector-options.mjs +1 -1
  38. package/server/public/app/plan-usage-view.mjs +36 -8
  39. package/server/public/app/pwa-install.js +5 -0
  40. package/server/public/app/relay-error-ctas.mjs +16 -2
  41. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  42. package/server/public/app/router.js +49 -12
  43. package/server/public/app/scrollbar-hover.mjs +78 -0
  44. package/server/public/app/settings-modal.js +37 -3
  45. package/server/public/app/settings-tabs.js +1 -1
  46. package/server/public/app/socket-handlers.js +53 -1
  47. package/server/public/app/store.js +26 -0
  48. package/server/public/app/transcript-separators.mjs +48 -11
  49. package/server/public/app-icon-192.png +0 -0
  50. package/server/public/app-icon-512.png +0 -0
  51. package/server/public/app-icon-light-192.png +0 -0
  52. package/server/public/app-icon-light-512.png +0 -0
  53. package/server/public/app-icon-light-maskable-512.png +0 -0
  54. package/server/public/app-icon-light.svg +4 -0
  55. package/server/public/app-icon-maskable-512.png +0 -0
  56. package/server/public/app-icon.svg +3 -3
  57. package/server/public/favicon.ico +0 -0
  58. package/server/public/index.html +564 -20
  59. package/server/public/manifest.webmanifest +9 -3
  60. package/server/public/sw.js +6 -1
  61. package/server/relay-tools.md +13 -1
  62. package/server/repositories/remote-relay-repository.mjs +42 -1
  63. package/server/repositories/session-repository.mjs +47 -1
  64. package/server/routes/claude-cloud-routes.mjs +140 -0
  65. package/server/routes/git-routes.mjs +37 -0
  66. package/server/routes/messages-routes.mjs +123 -16
  67. package/server/routes/remote-relay-routes.mjs +15 -1
  68. package/server/routes/sessions-routes.mjs +517 -74
  69. package/server/server-runtime.mjs +268 -33
  70. package/server/services/claude-account-usage-service.mjs +176 -0
  71. package/server/services/claude-cloud-repo-service.mjs +408 -0
  72. package/server/services/claude-cloud-session-service.mjs +326 -0
  73. package/server/services/claude-cloud-settings-service.mjs +269 -0
  74. package/server/services/claude-context-usage.mjs +51 -3
  75. package/server/services/git-remote-service.mjs +115 -0
  76. package/server/services/oar-cli-helpers.mjs +67 -1
  77. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  78. package/server/services/plan-usage-claude.mjs +202 -40
  79. package/server/services/plan-usage-contract.mjs +39 -3
  80. package/server/services/plan-usage-service.mjs +82 -2
  81. package/server/services/provider-reasoning-effort.mjs +3 -0
  82. package/server/services/pwa-manifest-theme.mjs +30 -0
  83. package/server/services/remote-relay-caller-context.mjs +49 -10
  84. package/server/services/remote-relay-client.mjs +9 -0
  85. package/server/services/remote-relay-dispatcher.mjs +383 -35
  86. package/server/services/remote-relay-inbound.mjs +49 -2
  87. package/server/services/remote-relay-loopback.mjs +245 -0
  88. package/server/services/remote-relay-registry-service.mjs +52 -0
  89. package/server/services/session-worker-launch-service.mjs +100 -15
  90. package/server/services/session-worker-process-service.mjs +348 -129
  91. package/server/services/session-worker-registry-service.mjs +18 -1
  92. package/server/services/session-worker-stop-service.mjs +24 -13
  93. package/server/services/session-worker-supervisor-service.mjs +56 -1
  94. package/server/services/update-install-service.mjs +9 -3
  95. package/server/services/usage-limit-pause-service.mjs +3 -1
  96. package/server/services/worker-launch-note-service.mjs +151 -0
  97. package/shared/claude-attribution.mjs +117 -0
  98. package/shared/claude-cloud/api-client.mjs +809 -0
  99. package/shared/claude-cloud/base-url.mjs +47 -0
  100. package/shared/claude-cloud/credentials.mjs +221 -0
  101. package/shared/claude-cloud/repo-url.mjs +111 -0
  102. package/shared/provider-routing.mjs +4 -2
  103. package/shared/remote-relay-contract.mjs +153 -22
  104. package/shared/remote-relay-tool-core.mjs +11 -8
  105. package/shared/steer-settle-failure.mjs +1 -0
@@ -49,7 +49,7 @@ import {
49
49
  closeSummaryModal,
50
50
  } from './store.js';
51
51
  import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
52
- import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
52
+ import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
53
53
  import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
54
54
  import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
55
55
  import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
@@ -88,8 +88,14 @@ import {
88
88
  normalizeRelayActivityEntry,
89
89
  promotedCompactBoundaryEntry,
90
90
  relayActivityEntryText,
91
+ visibleActivityEntries,
91
92
  } from './activity-replay-state.mjs';
92
- import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
93
+ import {
94
+ COMPACT_PENDING_STAMP,
95
+ SEPARATOR_CLASS,
96
+ syncSeparatorRail,
97
+ syncTranscriptSeparators,
98
+ } from './transcript-separators.mjs';
93
99
  import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
94
100
  import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
95
101
  import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
@@ -546,6 +552,7 @@ function syncSendButtonState() {
546
552
  steeringSupported: conversationSupportsSteering(currentConvId),
547
553
  steeringHeld: steeringHold.held,
548
554
  steeringHoldReason: steeringHold.reason,
555
+ archived: conversations[String(currentConvId || '').trim()]?.archived === true,
549
556
  });
550
557
  btn.disabled = state.disabled;
551
558
  btn.dataset.action = state.action;
@@ -1283,6 +1290,15 @@ function createMessageNode(msg, msgId = null, force = false) {
1283
1290
  const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
1284
1291
  if (steerMarkerClass) div.classList.add(steerMarkerClass);
1285
1292
  const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
1293
+ // The relay's note while this session's worker cannot be started
1294
+ // (worker-launch-note-service.mjs), rewritten in place as the episode goes
1295
+ // on. Once the relay has stopped trying, the note carries Retry: a launch
1296
+ // for once the cause is fixed.
1297
+ const isWorkerLaunchNote = msg.role === 'assistant' && messageKind.startsWith('worker-launch-');
1298
+ if (isWorkerLaunchNote) div.classList.add('msg-worker-launch', `msg-${messageKind}`);
1299
+ const workerLaunchActionsHtml = (!IS_SHARED_VIEW && msgId && messageKind === 'worker-launch-stopped')
1300
+ ? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn" data-action="retry-worker-launch" data-message-id="${escHtml(msgId)}" title="Start the worker for this conversation again now">Retry</button></div>`
1301
+ : '';
1286
1302
  // A turn answered by a different provider than the conversation is bound to
1287
1303
  // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
1288
1304
  // not silent: it ran on another plan than the header indicates.
@@ -1328,12 +1344,14 @@ function createMessageNode(msg, msgId = null, force = false) {
1328
1344
  // break row immediately above this message (syncTranscriptSeparators reads
1329
1345
  // the stamp), so it must not also show up inside the bubble's activity list.
1330
1346
  // Only the promoted (last) boundary is hidden — see renderActivityMarkup.
1347
+ // A rendered row is a finished message, so a compaction start without an
1348
+ // end draws no line here (the live bubble owns the running one).
1331
1349
  const promotedBoundaryEntry = promotedCompactBoundaryEntry(activities);
1332
1350
  const compactBoundary = compactBoundaryFromActivities(activities);
1333
1351
  if (compactBoundary) {
1334
1352
  div.dataset.compactBoundary = `${compactBoundary.preTokens ?? ''}|${compactBoundary.postTokens ?? ''}`;
1335
1353
  }
1336
- if (activities.some((item) => item !== promotedBoundaryEntry)) div.classList.add('msg-with-activity');
1354
+ if (visibleActivityEntries(activities, promotedBoundaryEntry).length) div.classList.add('msg-with-activity');
1337
1355
  const thoughts = Array.isArray(msg.thoughts) ? msg.thoughts.filter((t) => t && String(t.text || '').trim()) : [];
1338
1356
  const attachmentHtml = attachments.length ? renderAttachmentMarkup(attachments, { messageId: msgId }) : '';
1339
1357
  const subagentRuns = Array.isArray(msg.subagentRuns) ? msg.subagentRuns.filter(Boolean) : [];
@@ -1404,13 +1422,14 @@ function createMessageNode(msg, msgId = null, force = false) {
1404
1422
  ? `<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>`
1405
1423
  : '';
1406
1424
 
1407
- // Shared viewers get the badge without the link to the other relay.
1425
+ // Shared viewers get the badge without the link to the other relay (or, for
1426
+ // an agent on this relay, to the conversation it runs in).
1408
1427
  const originBadgeHtml = remoteOrigin
1409
- ? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW })
1428
+ ? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW, conversations })
1410
1429
  : '';
1411
1430
 
1412
1431
  div.innerHTML = `
1413
- <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
1432
+ <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}${workerLaunchActionsHtml}</div>
1414
1433
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
1415
1434
 
1416
1435
  const bubble = div.querySelector('.msg-bubble');
@@ -1468,7 +1487,15 @@ function insertMessageNode(node, insertAfterId = null) {
1468
1487
  const anchorId = String(insertAfterId || '').trim();
1469
1488
  const anchor = anchorId ? el.querySelector(`[data-message-id="${anchorId}"]`) : null;
1470
1489
  if (anchor && anchor.parentNode === el) {
1471
- const next = anchor.nextSibling;
1490
+ let next = anchor.nextSibling;
1491
+ // The line of a compaction that ran in this turn sits between the user
1492
+ // message and its live bubble; the reply lands under it, so the
1493
+ // separator pass relabels that line instead of replacing it.
1494
+ if (next?.nodeType === 1
1495
+ && next.classList?.contains(SEPARATOR_CLASS)
1496
+ && next.dataset?.separatorKey === `compact:${anchorId}`) {
1497
+ next = next.nextSibling;
1498
+ }
1472
1499
  if (next) el.insertBefore(node, next);
1473
1500
  else el.appendChild(node);
1474
1501
  } else {
@@ -1593,11 +1620,12 @@ export function renderActivityMarkup(activities, promotedBoundaryEntry = undefin
1593
1620
  // ones stay visible here as prose rather than disappearing from the
1594
1621
  // transcript altogether (one break row per message is all the separator
1595
1622
  // planner can place).
1623
+ // A compaction's start and cancel steps are never prose either.
1596
1624
  const list = Array.isArray(activities) ? activities : [];
1597
1625
  const promoted = promotedBoundaryEntry === undefined
1598
1626
  ? promotedCompactBoundaryEntry(list)
1599
1627
  : promotedBoundaryEntry;
1600
- const visible = list.filter((item) => item !== promoted);
1628
+ const visible = visibleActivityEntries(list, promoted);
1601
1629
  const progress = visible.filter((item) => relayActivityEntryText(item).startsWith('● '));
1602
1630
  const tools = visible.filter((item) => !relayActivityEntryText(item).startsWith('● '));
1603
1631
  const progressHtml = progress.length
@@ -1700,10 +1728,16 @@ function anchorThinkingBubble(div, messageId) {
1700
1728
  const id = String(messageId || '').trim();
1701
1729
  const target = id ? el.querySelector(`[data-message-id="${id}"]`) : null;
1702
1730
  if (target && target.parentNode === el) {
1703
- if (target.nextSibling === div) return;
1731
+ // The bubble's own compaction line sits between the two.
1732
+ let follower = target.nextSibling;
1733
+ while (follower && follower !== div && follower.classList?.contains('transcript-separator')) {
1734
+ follower = follower.nextSibling;
1735
+ }
1736
+ if (follower === div) return;
1704
1737
  const next = target.nextSibling;
1705
1738
  if (next) el.insertBefore(div, next);
1706
1739
  else el.appendChild(div);
1740
+ if (div.dataset.compactBoundary) syncSeparatorsNow();
1707
1741
  } else if (div.parentNode !== el) {
1708
1742
  el.appendChild(div);
1709
1743
  }
@@ -1760,11 +1794,13 @@ export function showThinking(messageId = null, autoScroll = true) {
1760
1794
  <div class="thinking-thoughts-list"></div>
1761
1795
  </details>
1762
1796
  <div id="thinking-stream" class="thinking-stream" hidden></div>
1797
+ <div class="thinking-compacting-note" hidden>Compacting the conversation…</div>
1763
1798
  <div class="dots"><span></span><span></span><span></span></div>
1764
1799
  <div id="thinking-activity" class="thinking-activity"></div>
1765
1800
  <div class="subagent-bubbles-container" data-subagent-bubbles-root="1"></div>
1766
1801
  </div>`;
1767
1802
  anchorThinkingBubble(div, nextMessageId);
1803
+ syncThinkingCompaction(div);
1768
1804
  renderThinkingThoughts();
1769
1805
  renderThinkingStream();
1770
1806
  // A board the turn has posted already lives in its live bubble.
@@ -1777,8 +1813,33 @@ export function showThinking(messageId = null, autoScroll = true) {
1777
1813
  export function removeThinking() {
1778
1814
  thinkingMessageId = null;
1779
1815
  lastInFlightSnapshotKey = '';
1816
+ const wasCompacting = !!document.getElementById('thinking-indicator')?.dataset?.compactBoundary;
1780
1817
  unobserveThinkingBubble();
1781
1818
  document.getElementById('thinking-indicator')?.remove();
1819
+ // The bubble's compaction line goes with it, but only after the
1820
+ // caller's same-tick work: a reply appended right after this call takes
1821
+ // the line over (relabelled in place) instead of it being dropped first.
1822
+ if (wasCompacting) queueMicrotask(syncSeparatorsNow);
1823
+ }
1824
+
1825
+ // The live turn's compaction line: the bubble stamps the turn's compaction
1826
+ // for the separator pass — "Compacting context…" while the last compaction
1827
+ // entry is a start without an end, the final label once the boundary is in —
1828
+ // so the line sits above the bubble and the reply takes it over unchanged.
1829
+ // The bubble's own note shows only while it runs.
1830
+ function syncThinkingCompaction(bubble = document.getElementById('thinking-indicator')) {
1831
+ if (!bubble) return;
1832
+ const items = thinkingMessageId ? (relayActivities.get(thinkingMessageId) || []) : [];
1833
+ const boundary = compactBoundaryFromActivities(items, { processing: true });
1834
+ const note = bubble.querySelector('.thinking-compacting-note');
1835
+ if (note) note.hidden = !boundary?.pending;
1836
+ const stamp = !boundary
1837
+ ? ''
1838
+ : (boundary.pending ? COMPACT_PENDING_STAMP : `${boundary.preTokens ?? ''}|${boundary.postTokens ?? ''}`);
1839
+ if ((bubble.dataset.compactBoundary || '') === stamp) return;
1840
+ if (stamp) bubble.dataset.compactBoundary = stamp;
1841
+ else delete bubble.dataset.compactBoundary;
1842
+ if (bubble.parentNode) syncSeparatorsNow();
1782
1843
  }
1783
1844
 
1784
1845
  // Stop is one tap away on a sticky header, so a stray touch must not kill a
@@ -1812,6 +1873,19 @@ function openStopTurnConfirmation(conversationId, messageId) {
1812
1873
  }, { once: true });
1813
1874
  }
1814
1875
 
1876
+ /** The Stop confirmation for a conversation's running turn, from outside the bubble (the sidebar menu). */
1877
+ export function openStopTurnConfirmationForConversation(conversationId) {
1878
+ const turn = getActiveTurnForConversation(conversationId);
1879
+ if (!turn?.messageId) return false;
1880
+ openStopTurnConfirmation(conversationId, turn.messageId);
1881
+ return true;
1882
+ }
1883
+
1884
+ /** Whether a turn of this conversation is running as far as this page knows. */
1885
+ export function conversationHasActiveTurn(conversationId) {
1886
+ return !!getActiveTurnForConversation(conversationId);
1887
+ }
1888
+
1815
1889
  export function collapseThinkingThoughts() {
1816
1890
  const panel = document.getElementById('thinking-thoughts');
1817
1891
  if (!(panel instanceof HTMLDetailsElement)) return;
@@ -1931,6 +2005,7 @@ export function renderThinkingActivities() {
1931
2005
  if (!selectionIntersectsNode(box)) {
1932
2006
  patchActivityList(box, mainExpected, 'thinking-activity-item');
1933
2007
  }
2008
+ syncThinkingCompaction();
1934
2009
  for (const [subagentRunId, expected] of bySubagentRun) {
1935
2010
  const bubble = ensureSubagentBubble(subagentRunId);
1936
2011
  const runBox = bubble?.querySelector('.subagent-activity');
@@ -2036,8 +2111,12 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2036
2111
  );
2037
2112
  if (!entry) return;
2038
2113
  // Same rule as renderThinkingActivities: a boundary becomes a break row on
2039
- // the persisted message, so it never shows as live prose.
2040
- if (isCompactBoundaryActivityEntry(entry)) return;
2114
+ // the persisted message, so it never shows as live prose. Every
2115
+ // compaction step does move the live turn's compaction line, though.
2116
+ if (isCompactBoundaryActivityEntry(entry)) {
2117
+ syncThinkingCompaction();
2118
+ return;
2119
+ }
2041
2120
  const decorated = decorateActivityText(entry.text);
2042
2121
 
2043
2122
  if (subagentRunId) {
@@ -2773,6 +2852,55 @@ function releaseStoppedSteerResendsOf(messageId) {
2773
2852
  }
2774
2853
  }
2775
2854
 
2855
+ // Retry on the worker-launch note: ask the relay for a launch now. The relay
2856
+ // rewrites the note itself — the worker started, or the launches are
2857
+ // exhausted again — so the button only has to report a refused request.
2858
+ async function retryWorkerLaunch(conversationId, noteId) {
2859
+ const conversationKey = String(conversationId || '').trim();
2860
+ const id = String(noteId || '').trim();
2861
+ if (!conversationKey || !id) return;
2862
+ const btn = document.querySelector(`.bubble-action-btn[data-message-id="${CSS.escape(id)}"][data-action="retry-worker-launch"]`);
2863
+ if (btn?.disabled) return;
2864
+ const sdkSessionId = String(conversations[conversationKey]?.sdkSessionId || '').trim();
2865
+ if (!sdkSessionId) {
2866
+ showTransientRelayNotice('This conversation has no session to start a worker for.');
2867
+ return;
2868
+ }
2869
+ if (btn) {
2870
+ btn.disabled = true;
2871
+ btn.textContent = 'Retrying…';
2872
+ }
2873
+ try {
2874
+ // apiFetch answers null for a refused request (the route says 409 with
2875
+ // the reason) as for a network failure: both re-arm the button.
2876
+ const result = await launchSessionWorker(sdkSessionId);
2877
+ if (!result || result.ok === false) throw new Error(String(result?.error || 'the relay refused the launch'));
2878
+ } catch (error) {
2879
+ showTransientRelayNotice(`The worker could not be started: ${error?.message || error}`);
2880
+ if (btn) {
2881
+ btn.disabled = false;
2882
+ btn.textContent = 'Retry';
2883
+ }
2884
+ }
2885
+ }
2886
+
2887
+ // A message the relay rewrote in place (the worker-launch note): the bubble is
2888
+ // rebuilt where it stands, so the transcript keeps its order. False when the
2889
+ // message is not on the page.
2890
+ export function replaceRenderedMessage(msg, msgId) {
2891
+ const el = getMessagesElement();
2892
+ const id = String(msgId || '').trim();
2893
+ if (!el || !id) return false;
2894
+ const existing = el.querySelector(`[data-message-id="${CSS.escape(id)}"]`);
2895
+ if (!existing) return false;
2896
+ const placeholder = document.createComment('replacing');
2897
+ existing.replaceWith(placeholder);
2898
+ seenMessageIds.delete(id);
2899
+ const fresh = createMessageNode(msg, id, true);
2900
+ placeholder.replaceWith(fresh || existing);
2901
+ return Boolean(fresh);
2902
+ }
2903
+
2776
2904
  async function resendStoppedSteer(conversationId, markerId) {
2777
2905
  const conversationKey = String(conversationId || '').trim();
2778
2906
  const id = String(markerId || '').trim();
@@ -3112,6 +3240,12 @@ function handleBubbleActionClick(event) {
3112
3240
  void resendStoppedSteer(currentConvId, messageId);
3113
3241
  }
3114
3242
 
3243
+ if (action === 'retry-worker-launch' && messageId) {
3244
+ event.preventDefault();
3245
+ event.stopPropagation();
3246
+ void retryWorkerLaunch(currentConvId, messageId);
3247
+ }
3248
+
3115
3249
  if (action === 'stop-subagent' && subagentRunId) {
3116
3250
  event.preventDefault();
3117
3251
  event.stopPropagation();
@@ -23,6 +23,7 @@ import {
23
23
  buildKnownCwdOptions as buildKnownCwdOptionsFromInputs,
24
24
  normalizeKnownCwdPath,
25
25
  } from './known-cwd-options.mjs';
26
+ import { buildCloudLineModel, cloudHeaderLabel, isClaudeCloudConversation } from './claude-cloud-ui.mjs';
26
27
 
27
28
  export { normalizeKnownCwdPath };
28
29
 
@@ -462,6 +463,17 @@ export function syncChatHeaderWorkspaceLabel() {
462
463
  const labelEl = document.getElementById('chat-title-cwd');
463
464
  if (!labelEl) return;
464
465
  const convId = String(currentConvId || '').trim();
466
+ const conversation = convId ? (conversations?.[convId] || null) : null;
467
+ if (isClaudeCloudConversation(conversation)) {
468
+ // A cloud chat runs on a clone at Anthropic, so the line names the
469
+ // repository instead of a folder on the relay host.
470
+ const label = cloudHeaderLabel(conversation.cloud);
471
+ labelEl.hidden = !label;
472
+ labelEl.textContent = label;
473
+ if (label) labelEl.title = buildCloudLineModel(conversation.cloud)?.repoUrl || label;
474
+ else labelEl.removeAttribute('title');
475
+ return;
476
+ }
465
477
  const cwd = getSelectedConversationCurrentCwd();
466
478
  if (!convId || !cwd) {
467
479
  labelEl.hidden = true;