@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -0,0 +1,140 @@
1
+ // A context menu for the conversation list: right-click on a desktop, a long
2
+ // press on a phone. One menu at a time, closed by a pointer-down outside it,
3
+ // Escape, a scroll or a resize, like a native menu; flipped at the screen
4
+ // edge rather than clipped. The entries are buttons, so keyboard users get
5
+ // focus on the first one.
6
+
7
+ const LONG_PRESS_MS = 500;
8
+ const LONG_PRESS_SLOP_PX = 10;
9
+
10
+ /** A horizontal rule between groups. */
11
+ export const MENU_SEPARATOR = null;
12
+
13
+ let closeOpenMenu = null;
14
+
15
+ function esc(value) {
16
+ return String(value ?? '')
17
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
18
+ }
19
+
20
+ /** Close the open menu, if any. */
21
+ export function closeContextMenu() {
22
+ closeOpenMenu?.();
23
+ }
24
+
25
+ /**
26
+ * Open a menu at viewport coordinates. `items`: `{ label, onSelect, danger?,
27
+ * disabled?, id? }`, or MENU_SEPARATOR between groups. Returns the menu node.
28
+ */
29
+ export function openContextMenu(x, y, items, { documentRef = document, windowRef = window } = {}) {
30
+ closeOpenMenu?.();
31
+ const menu = documentRef.createElement('div');
32
+ menu.className = 'context-menu';
33
+ menu.setAttribute('role', 'menu');
34
+
35
+ const close = () => {
36
+ if (closeOpenMenu !== close) return;
37
+ closeOpenMenu = null;
38
+ documentRef.removeEventListener('pointerdown', onOutside, true);
39
+ documentRef.removeEventListener('keydown', onKey, true);
40
+ windowRef.removeEventListener('resize', close);
41
+ windowRef.removeEventListener('scroll', close, true);
42
+ menu.remove();
43
+ };
44
+ const onOutside = (event) => {
45
+ if (!menu.contains(event.target)) close();
46
+ };
47
+ const onKey = (event) => {
48
+ if (event.key === 'Escape') {
49
+ event.stopPropagation();
50
+ close();
51
+ return;
52
+ }
53
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
54
+ const buttons = [...menu.querySelectorAll('button:not([disabled])')];
55
+ if (!buttons.length) return;
56
+ const index = buttons.indexOf(documentRef.activeElement);
57
+ const next = event.key === 'ArrowDown'
58
+ ? buttons[(index + 1) % buttons.length]
59
+ : buttons[(index <= 0 ? buttons.length : index) - 1];
60
+ next.focus();
61
+ event.preventDefault();
62
+ };
63
+
64
+ for (const item of items) {
65
+ if (item === MENU_SEPARATOR) {
66
+ const rule = documentRef.createElement('div');
67
+ rule.className = 'context-menu-sep';
68
+ rule.setAttribute('role', 'separator');
69
+ menu.append(rule);
70
+ continue;
71
+ }
72
+ if (!item) continue;
73
+ const entry = documentRef.createElement('button');
74
+ entry.type = 'button';
75
+ entry.className = `context-menu-item${item.danger ? ' context-menu-item-danger' : ''}`;
76
+ entry.setAttribute('role', 'menuitem');
77
+ if (item.id) entry.dataset.menuItem = String(item.id);
78
+ entry.innerHTML = esc(item.label);
79
+ entry.disabled = item.disabled === true;
80
+ entry.addEventListener('click', (event) => {
81
+ event.preventDefault();
82
+ event.stopPropagation();
83
+ close();
84
+ item.onSelect?.();
85
+ });
86
+ menu.append(entry);
87
+ }
88
+
89
+ documentRef.body.append(menu);
90
+ // Positioned after insertion so the measured size is the real one.
91
+ const rect = menu.getBoundingClientRect();
92
+ const left = Math.max(4, Math.min(x, windowRef.innerWidth - rect.width - 4));
93
+ const top = Math.max(4, Math.min(y, windowRef.innerHeight - rect.height - 4));
94
+ menu.style.left = `${left}px`;
95
+ menu.style.top = `${top}px`;
96
+
97
+ closeOpenMenu = close;
98
+ documentRef.addEventListener('pointerdown', onOutside, true);
99
+ documentRef.addEventListener('keydown', onKey, true);
100
+ windowRef.addEventListener('resize', close);
101
+ windowRef.addEventListener('scroll', close, true);
102
+ queueMicrotask(() => menu.querySelector('button:not([disabled])')?.focus());
103
+ return menu;
104
+ }
105
+
106
+ /**
107
+ * Long-press as the touch equivalent of right-click, on every descendant of
108
+ * `root` that matches `selector`. Android fires no `contextmenu` for a long
109
+ * press on a non-selectable element. Cancelled by movement, so it never fires
110
+ * mid-scroll; mouse pointers are left to the real `contextmenu` event.
111
+ * `handler(target, x, y)`.
112
+ */
113
+ export function bindLongPress(root, selector, handler, { setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout } = {}) {
114
+ let timer = null;
115
+ let startX = 0;
116
+ let startY = 0;
117
+ const cancel = () => {
118
+ if (timer !== null) clearTimeoutImpl(timer);
119
+ timer = null;
120
+ };
121
+ root.addEventListener('pointerdown', (event) => {
122
+ if (event.pointerType === 'mouse') return;
123
+ const target = event.target?.closest?.(selector);
124
+ if (!target || !root.contains(target)) return;
125
+ startX = event.clientX;
126
+ startY = event.clientY;
127
+ cancel();
128
+ timer = setTimeoutImpl(() => {
129
+ timer = null;
130
+ handler(target, startX, startY);
131
+ }, LONG_PRESS_MS);
132
+ }, { passive: true });
133
+ root.addEventListener('pointermove', (event) => {
134
+ if (Math.abs(event.clientX - startX) > LONG_PRESS_SLOP_PX || Math.abs(event.clientY - startY) > LONG_PRESS_SLOP_PX) cancel();
135
+ }, { passive: true });
136
+ for (const name of ['pointerup', 'pointercancel', 'pointerleave']) {
137
+ root.addEventListener(name, cancel, { passive: true });
138
+ }
139
+ return cancel;
140
+ }
@@ -55,6 +55,10 @@ export function buildContextTierOptions({
55
55
  const meta = metadata && typeof metadata === 'object' ? metadata : {};
56
56
  const defaultLabel = tokenLabel(defaultWindowTokens(meta));
57
57
  const provider = String(providerType || '').trim().toLowerCase();
58
+ // A cloud session has no tier to choose and its window is not in the merged
59
+ // metadata either (that number is Copilot's): one blank default, as for a
60
+ // Claude model whose window is unknown.
61
+ if (provider === 'claude-cloud') return [{ value: 'default', label: UNKNOWN_WINDOW_LABEL }];
58
62
  const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
59
63
  if (claudeTierList) {
60
64
  return claudeTierList
@@ -0,0 +1,27 @@
1
+ // The header's "via agent · “title”" line: the conversation on screen was
2
+ // started by an agent in another conversation on this relay, and the line
3
+ // opens that conversation. Derived from the conversation record alone, so the
4
+ // one sync function is idempotent and runs with every header sync. A
5
+ // conversation another relay's agent created has no header line (its sidebar
6
+ // marker is all there is), and neither has one a person started.
7
+
8
+ import { conversations, currentConvId, IS_SHARED_VIEW } from './store.js';
9
+ import { renderConversationHeaderOriginHtml } from './remote-relay-origin-view.mjs';
10
+
11
+ let renderedHtml = '';
12
+
13
+ export function syncConversationOriginHeader() {
14
+ const line = document.getElementById('chat-title-origin');
15
+ if (!line) return;
16
+ const id = String(currentConvId || '').trim();
17
+ const conversation = id ? (conversations[id] || null) : null;
18
+ const html = conversation
19
+ ? renderConversationHeaderOriginHtml(conversation, { conversations, linkable: !IS_SHARED_VIEW })
20
+ : '';
21
+ // This runs on every list render; the button must not be rebuilt under a tap.
22
+ if (html !== renderedHtml) {
23
+ line.innerHTML = html;
24
+ renderedHtml = html;
25
+ }
26
+ line.hidden = !html;
27
+ }
@@ -23,23 +23,39 @@ export function isConversationUsingGrokProvider(conversation = null) {
23
23
  return resolveConversationProviderType(conversation) === 'grok';
24
24
  }
25
25
 
26
- // An unresolved provider stays unlabelled rather than defaulting to Copilot, so
27
- // a conversation the client has not loaded yet cannot claim the wrong provider.
28
- export function conversationProviderIndicatorLabel(conversation = null) {
26
+ // Its own provider, never a flavour of `claude`: the session runs in a sandbox
27
+ // at Anthropic, so nothing that is true of a local Claude worker (modes,
28
+ // effort, steering, a working directory) may be assumed for it.
29
+ export function isConversationUsingClaudeCloudProvider(conversation = null) {
30
+ return resolveConversationProviderType(conversation) === 'claude-cloud';
31
+ }
32
+
33
+ // The marker that tells a cloud chat from a local Claude one at a glance.
34
+ const CLAUDE_CLOUD_MARKER = '☁';
35
+
36
+ function conversationProviderName(conversation = null) {
29
37
  const providerType = resolveConversationProviderType(conversation);
30
38
  if (providerType === 'openai') return 'OpenAI';
31
39
  if (providerType === 'claude') return 'Claude';
40
+ if (providerType === 'claude-cloud') return 'Claude Cloud';
32
41
  if (providerType === 'cursor') return 'Cursor';
33
42
  if (providerType === 'grok') return 'Grok';
34
43
  if (providerType === 'github' || providerType === 'github-copilot') return 'Copilot';
35
44
  return '';
36
45
  }
37
46
 
47
+ // An unresolved provider stays unlabelled rather than defaulting to Copilot, so
48
+ // a conversation the client has not loaded yet cannot claim the wrong provider.
49
+ export function conversationProviderIndicatorLabel(conversation = null) {
50
+ const name = conversationProviderName(conversation);
51
+ return isConversationUsingClaudeCloudProvider(conversation) ? `${CLAUDE_CLOUD_MARKER} ${name}` : name;
52
+ }
53
+
38
54
  // A question card names the runtime that asks, the way the list pill names it.
39
55
  // Unresolved reads plain "Question": every card used to say "Copilot question",
40
56
  // Claude's and Cursor's included.
41
57
  export function questionCardHeadLabel(conversation = null) {
42
- const provider = conversationProviderIndicatorLabel(conversation);
58
+ const provider = conversationProviderName(conversation);
43
59
  return provider ? `${provider} question` : 'Question';
44
60
  }
45
61
 
@@ -50,6 +66,7 @@ const SESSION_LOCK_LABELS = {
50
66
  claude: 'Claude SDK',
51
67
  cursor: 'Cursor SDK',
52
68
  grok: 'Grok',
69
+ 'claude-cloud': `${CLAUDE_CLOUD_MARKER} Claude Cloud`,
53
70
  };
54
71
 
55
72
  export function isOpenAIImageModelId(modelId = '') {
@@ -66,6 +83,7 @@ export function sessionLockProviderKey({ providerType = '', model = '' } = {}) {
66
83
  }
67
84
  if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
68
85
  if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
86
+ if (normalized === 'claude-cloud') return 'claude-cloud';
69
87
  if (normalized === 'cursor') return 'cursor';
70
88
  if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
71
89
  if (normalized === 'github' || normalized === 'github-copilot') return 'github';
@@ -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';
@@ -91,6 +91,7 @@ import {
91
91
  } from './activity-replay-state.mjs';
92
92
  import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
93
93
  import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
94
+ import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
94
95
  import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
95
96
  import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
96
97
  import { createInfiniteLoader } from './infinite-loader.js';
@@ -494,7 +495,7 @@ function cancellableSteerIdsForConversation(conversationId) {
494
495
  // and why. Two sources, freshest first: the client's own knowledge of an open
495
496
  // question card (instant — the worker's heartbeat snapshot lags up to ~10s),
496
497
  // then the worker-reported steering snapshot for the slower holds only it can
497
- // see (compaction, post-compaction adoption).
498
+ // see (compaction, post-compaction adoption, a running tool call).
498
499
  function steeringHoldForConversation(conversationId) {
499
500
  const conversationKey = String(conversationId || '').trim();
500
501
  if (!conversationKey || !conversationSupportsSteering(conversationKey)) {
@@ -545,6 +546,7 @@ function syncSendButtonState() {
545
546
  steeringSupported: conversationSupportsSteering(currentConvId),
546
547
  steeringHeld: steeringHold.held,
547
548
  steeringHoldReason: steeringHold.reason,
549
+ archived: conversations[String(currentConvId || '').trim()]?.archived === true,
548
550
  });
549
551
  btn.disabled = state.disabled;
550
552
  btn.dataset.action = state.action;
@@ -1282,6 +1284,15 @@ function createMessageNode(msg, msgId = null, force = false) {
1282
1284
  const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
1283
1285
  if (steerMarkerClass) div.classList.add(steerMarkerClass);
1284
1286
  const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
1287
+ // The relay's note while this session's worker cannot be started
1288
+ // (worker-launch-note-service.mjs), rewritten in place as the episode goes
1289
+ // on. Once the relay has stopped trying, the note carries Retry: a launch
1290
+ // for once the cause is fixed.
1291
+ const isWorkerLaunchNote = msg.role === 'assistant' && messageKind.startsWith('worker-launch-');
1292
+ if (isWorkerLaunchNote) div.classList.add('msg-worker-launch', `msg-${messageKind}`);
1293
+ const workerLaunchActionsHtml = (!IS_SHARED_VIEW && msgId && messageKind === 'worker-launch-stopped')
1294
+ ? `<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>`
1295
+ : '';
1285
1296
  // A turn answered by a different provider than the conversation is bound to
1286
1297
  // (e.g. the Copilot relay answering a Cursor conversation) must be visible,
1287
1298
  // not silent: it ran on another plan than the header indicates.
@@ -1403,13 +1414,14 @@ function createMessageNode(msg, msgId = null, force = false) {
1403
1414
  ? `<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>`
1404
1415
  : '';
1405
1416
 
1406
- // Shared viewers get the badge without the link to the other relay.
1417
+ // Shared viewers get the badge without the link to the other relay (or, for
1418
+ // an agent on this relay, to the conversation it runs in).
1407
1419
  const originBadgeHtml = remoteOrigin
1408
- ? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW })
1420
+ ? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW, conversations })
1409
1421
  : '';
1410
1422
 
1411
1423
  div.innerHTML = `
1412
- <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
1424
+ <div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}${workerLaunchActionsHtml}</div>
1413
1425
  <div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
1414
1426
 
1415
1427
  const bubble = div.querySelector('.msg-bubble');
@@ -1766,6 +1778,8 @@ export function showThinking(messageId = null, autoScroll = true) {
1766
1778
  anchorThinkingBubble(div, nextMessageId);
1767
1779
  renderThinkingThoughts();
1768
1780
  renderThinkingStream();
1781
+ // A board the turn has posted already lives in its live bubble.
1782
+ renderRelayBoards();
1769
1783
  observeThinkingBubble(div);
1770
1784
  if (getThinkingFollowMode()) applyThinkingFollow();
1771
1785
  else if (autoScroll) scrollBottom();
@@ -1809,6 +1823,19 @@ function openStopTurnConfirmation(conversationId, messageId) {
1809
1823
  }, { once: true });
1810
1824
  }
1811
1825
 
1826
+ /** The Stop confirmation for a conversation's running turn, from outside the bubble (the sidebar menu). */
1827
+ export function openStopTurnConfirmationForConversation(conversationId) {
1828
+ const turn = getActiveTurnForConversation(conversationId);
1829
+ if (!turn?.messageId) return false;
1830
+ openStopTurnConfirmation(conversationId, turn.messageId);
1831
+ return true;
1832
+ }
1833
+
1834
+ /** Whether a turn of this conversation is running as far as this page knows. */
1835
+ export function conversationHasActiveTurn(conversationId) {
1836
+ return !!getActiveTurnForConversation(conversationId);
1837
+ }
1838
+
1812
1839
  export function collapseThinkingThoughts() {
1813
1840
  const panel = document.getElementById('thinking-thoughts');
1814
1841
  if (!(panel instanceof HTMLDetailsElement)) return;
@@ -1885,6 +1912,7 @@ function renderSubagentStream(subagentRunId, text) {
1885
1912
  }
1886
1913
  box.hidden = false;
1887
1914
  patchRenderedMarkdown(box, renderMarkdownPreview(value, false));
1915
+ refreshSubagentSummary(bubble);
1888
1916
  }
1889
1917
 
1890
1918
  function patchActivityList(box, expectedTexts, className) {
@@ -1932,6 +1960,7 @@ export function renderThinkingActivities() {
1932
1960
  const runBox = bubble?.querySelector('.subagent-activity');
1933
1961
  if (runBox && !selectionIntersectsNode(runBox)) {
1934
1962
  patchActivityList(runBox, expected, 'subagent-activity-item');
1963
+ refreshSubagentSummary(bubble);
1935
1964
  }
1936
1965
  }
1937
1966
  }
@@ -2046,6 +2075,7 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
2046
2075
  row.className = 'subagent-activity-item';
2047
2076
  row.textContent = decorated;
2048
2077
  activityBox.appendChild(row);
2078
+ refreshSubagentSummary(subagentBubble);
2049
2079
  }
2050
2080
  }
2051
2081
  if (autoScroll) scrollBottom();
@@ -2125,6 +2155,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2125
2155
  const status = getSubagentStatus(id);
2126
2156
  updateSubagentBubbleStatus(bubble, status);
2127
2157
  updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
2158
+ applySubagentFold(id);
2128
2159
  return bubble;
2129
2160
  }
2130
2161
 
@@ -2136,8 +2167,19 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2136
2167
  bubble.className = 'subagent-bubble';
2137
2168
  bubble.dataset.subagentRunId = id;
2138
2169
 
2170
+ // The whole header folds and unfolds the bubble (a phone needs the large
2171
+ // target); the Stop button inside it keeps its own click.
2139
2172
  const header = document.createElement('div');
2140
2173
  header.className = 'subagent-bubble-header';
2174
+ header.dataset.action = 'toggle-subagent';
2175
+ header.dataset.subagentRunId = id;
2176
+ header.setAttribute('role', 'button');
2177
+ header.tabIndex = 0;
2178
+
2179
+ const foldMark = document.createElement('span');
2180
+ foldMark.className = 'subagent-bubble-fold';
2181
+ foldMark.setAttribute('aria-hidden', 'true');
2182
+ header.appendChild(foldMark);
2141
2183
 
2142
2184
  const nameSpan = document.createElement('span');
2143
2185
  nameSpan.className = 'subagent-bubble-name';
@@ -2151,6 +2193,10 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2151
2193
  ? '● Running'
2152
2194
  : normalizeSubagentBubbleStatus(status).charAt(0).toUpperCase() + normalizeSubagentBubbleStatus(status).slice(1);
2153
2195
 
2196
+ // What the run is doing, in one line, for the header of a folded bubble.
2197
+ const summarySpan = document.createElement('span');
2198
+ summarySpan.className = 'subagent-bubble-summary';
2199
+
2154
2200
  const controls = document.createElement('div');
2155
2201
  controls.className = 'subagent-bubble-controls';
2156
2202
  controls.appendChild(statusSpan);
@@ -2163,6 +2209,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2163
2209
  controls.appendChild(stopBtn);
2164
2210
 
2165
2211
  header.appendChild(nameSpan);
2212
+ header.appendChild(summarySpan);
2166
2213
  header.appendChild(controls);
2167
2214
 
2168
2215
  const activityBox = document.createElement('div');
@@ -2176,6 +2223,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2176
2223
  bubble.appendChild(activityBox);
2177
2224
  container.appendChild(bubble);
2178
2225
  updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
2226
+ applySubagentFold(id);
2179
2227
 
2180
2228
  const entry = getSubagentRun(id);
2181
2229
  if (entry?.thoughts?.length) {
@@ -2205,10 +2253,22 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
2205
2253
  if (entry?.streamText) {
2206
2254
  renderSubagentStream(id, entry.streamText);
2207
2255
  }
2256
+ refreshSubagentSummary(bubble);
2208
2257
 
2209
2258
  return bubble;
2210
2259
  }
2211
2260
 
2261
+ /** Bring the folded header's line up to date with what the bubble holds. */
2262
+ function refreshSubagentSummary(bubble) {
2263
+ const summary = bubble?.querySelector(':scope > .subagent-bubble-header > .subagent-bubble-summary');
2264
+ if (!summary) return;
2265
+ const text = subagentFoldSummary({
2266
+ activities: Array.from(bubble.querySelectorAll(':scope > .subagent-activity > .subagent-activity-item'), (row) => row.textContent || ''),
2267
+ streamText: bubble.querySelector(':scope > .subagent-stream')?.textContent || '',
2268
+ });
2269
+ if (summary.textContent !== text) summary.textContent = text;
2270
+ }
2271
+
2212
2272
  function updateSubagentBubbleStatus(bubble, status) {
2213
2273
  if (!bubble) return;
2214
2274
  const statusSpan = bubble.querySelector('.subagent-bubble-status');
@@ -2218,6 +2278,38 @@ function updateSubagentBubbleStatus(bubble, status) {
2218
2278
  statusSpan.textContent = normalized === 'running' ? '● Running' : normalized.charAt(0).toUpperCase() + normalized.slice(1);
2219
2279
  }
2220
2280
 
2281
+ // What the user chose by hand for a live subagent bubble ('open' | 'folded'),
2282
+ // by run id. Kept outside the DOM: the live bubble is rebuilt on a
2283
+ // conversation switch and on reload-restore, and the choice must survive it.
2284
+ const subagentFoldChoices = new Map();
2285
+ const MAX_SUBAGENT_FOLD_CHOICES = 500;
2286
+
2287
+ /** Show a live subagent bubble folded or open (see subagent-fold.mjs). */
2288
+ function applySubagentFold(subagentRunId) {
2289
+ const id = String(subagentRunId || '').trim();
2290
+ if (!id) return;
2291
+ const bubble = document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`);
2292
+ if (!bubble) return;
2293
+ const folded = resolveSubagentFold({ choice: subagentFoldChoices.get(id) || null });
2294
+ bubble.classList.toggle('folded', folded);
2295
+ const header = bubble.querySelector(':scope > .subagent-bubble-header');
2296
+ if (header) {
2297
+ header.setAttribute('aria-expanded', folded ? 'false' : 'true');
2298
+ header.title = folded ? 'Show what this subagent did' : 'Fold this subagent';
2299
+ }
2300
+ }
2301
+
2302
+ function toggleSubagentFold(subagentRunId) {
2303
+ const id = String(subagentRunId || '').trim();
2304
+ const bubble = id ? document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`) : null;
2305
+ if (!bubble) return;
2306
+ while (subagentFoldChoices.size >= MAX_SUBAGENT_FOLD_CHOICES) {
2307
+ subagentFoldChoices.delete(subagentFoldChoices.keys().next().value);
2308
+ }
2309
+ subagentFoldChoices.set(id, toggledSubagentFoldChoice(bubble.classList.contains('folded')));
2310
+ applySubagentFold(id);
2311
+ }
2312
+
2221
2313
  const subagentStopUnsupported = new Set();
2222
2314
 
2223
2315
  /** The provider answered "not supported": the control must not re-arm. */
@@ -2238,6 +2330,11 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
2238
2330
  btn.disabled = true;
2239
2331
  return;
2240
2332
  }
2333
+ const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
2334
+ const terminal = isSubagentTerminalStatus(status);
2335
+ // Nothing is left to stop once the run has ended; a disabled button there
2336
+ // only took the room the name needs.
2337
+ btn.hidden = terminal;
2241
2338
  if (subagentStopUnsupported.has(id)) {
2242
2339
  btn.disabled = true;
2243
2340
  btn.textContent = 'Stop unavailable';
@@ -2245,8 +2342,6 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
2245
2342
  btn.classList.remove('stopping');
2246
2343
  return;
2247
2344
  }
2248
- const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
2249
- const terminal = isSubagentTerminalStatus(status);
2250
2345
  const stopping = !!isStopping;
2251
2346
  btn.disabled = terminal || stopping;
2252
2347
  btn.textContent = stopping ? 'Stopping…' : 'Stop';
@@ -2265,6 +2360,7 @@ export function updateSubagentBubbleFromStatus(subagentRunId, status) {
2265
2360
  clearSubagentCancelInFlight(id);
2266
2361
  }
2267
2362
  updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
2363
+ applySubagentFold(id);
2268
2364
  }
2269
2365
 
2270
2366
  function renderSubagentBubbleRecursive(entry) {
@@ -2701,6 +2797,55 @@ function releaseStoppedSteerResendsOf(messageId) {
2701
2797
  }
2702
2798
  }
2703
2799
 
2800
+ // Retry on the worker-launch note: ask the relay for a launch now. The relay
2801
+ // rewrites the note itself — the worker started, or the launches are
2802
+ // exhausted again — so the button only has to report a refused request.
2803
+ async function retryWorkerLaunch(conversationId, noteId) {
2804
+ const conversationKey = String(conversationId || '').trim();
2805
+ const id = String(noteId || '').trim();
2806
+ if (!conversationKey || !id) return;
2807
+ const btn = document.querySelector(`.bubble-action-btn[data-message-id="${CSS.escape(id)}"][data-action="retry-worker-launch"]`);
2808
+ if (btn?.disabled) return;
2809
+ const sdkSessionId = String(conversations[conversationKey]?.sdkSessionId || '').trim();
2810
+ if (!sdkSessionId) {
2811
+ showTransientRelayNotice('This conversation has no session to start a worker for.');
2812
+ return;
2813
+ }
2814
+ if (btn) {
2815
+ btn.disabled = true;
2816
+ btn.textContent = 'Retrying…';
2817
+ }
2818
+ try {
2819
+ // apiFetch answers null for a refused request (the route says 409 with
2820
+ // the reason) as for a network failure: both re-arm the button.
2821
+ const result = await launchSessionWorker(sdkSessionId);
2822
+ if (!result || result.ok === false) throw new Error(String(result?.error || 'the relay refused the launch'));
2823
+ } catch (error) {
2824
+ showTransientRelayNotice(`The worker could not be started: ${error?.message || error}`);
2825
+ if (btn) {
2826
+ btn.disabled = false;
2827
+ btn.textContent = 'Retry';
2828
+ }
2829
+ }
2830
+ }
2831
+
2832
+ // A message the relay rewrote in place (the worker-launch note): the bubble is
2833
+ // rebuilt where it stands, so the transcript keeps its order. False when the
2834
+ // message is not on the page.
2835
+ export function replaceRenderedMessage(msg, msgId) {
2836
+ const el = getMessagesElement();
2837
+ const id = String(msgId || '').trim();
2838
+ if (!el || !id) return false;
2839
+ const existing = el.querySelector(`[data-message-id="${CSS.escape(id)}"]`);
2840
+ if (!existing) return false;
2841
+ const placeholder = document.createComment('replacing');
2842
+ existing.replaceWith(placeholder);
2843
+ seenMessageIds.delete(id);
2844
+ const fresh = createMessageNode(msg, id, true);
2845
+ placeholder.replaceWith(fresh || existing);
2846
+ return Boolean(fresh);
2847
+ }
2848
+
2704
2849
  async function resendStoppedSteer(conversationId, markerId) {
2705
2850
  const conversationKey = String(conversationId || '').trim();
2706
2851
  const id = String(markerId || '').trim();
@@ -2992,7 +3137,7 @@ function runRelayErrorCta(cta) {
2992
3137
  }
2993
3138
 
2994
3139
  function handleBubbleActionClick(event) {
2995
- const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn');
3140
+ const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn, .subagent-bubble-header');
2996
3141
  if (!btn) return;
2997
3142
  const action = btn.dataset.action;
2998
3143
  const messageId = btn.dataset.messageId;
@@ -3040,11 +3185,33 @@ function handleBubbleActionClick(event) {
3040
3185
  void resendStoppedSteer(currentConvId, messageId);
3041
3186
  }
3042
3187
 
3188
+ if (action === 'retry-worker-launch' && messageId) {
3189
+ event.preventDefault();
3190
+ event.stopPropagation();
3191
+ void retryWorkerLaunch(currentConvId, messageId);
3192
+ }
3193
+
3043
3194
  if (action === 'stop-subagent' && subagentRunId) {
3044
3195
  event.preventDefault();
3045
3196
  event.stopPropagation();
3046
3197
  void cancelSubagentByRunId(currentConvId, subagentRunId);
3047
3198
  }
3199
+
3200
+ if (action === 'toggle-subagent' && subagentRunId) {
3201
+ // A tap that ends a text selection inside the header is not a toggle.
3202
+ if (String(window.getSelection?.() || '').trim()) return;
3203
+ event.preventDefault();
3204
+ toggleSubagentFold(subagentRunId);
3205
+ }
3206
+ }
3207
+
3208
+ // Enter and Space on a focused subagent header, like the click.
3209
+ function handleBubbleActionKeydown(event) {
3210
+ if (event.key !== 'Enter' && event.key !== ' ') return;
3211
+ const header = event.target.closest?.('.subagent-bubble-header');
3212
+ if (!header || event.target !== header) return;
3213
+ event.preventDefault();
3214
+ toggleSubagentFold(header.dataset.subagentRunId);
3048
3215
  }
3049
3216
 
3050
3217
  // Clicking an inline-embedded image in a message bubble opens it in the file
@@ -3077,6 +3244,7 @@ export function initBubbleActionHandlers() {
3077
3244
  const messagesEl = document.getElementById('messages');
3078
3245
  if (!messagesEl) return;
3079
3246
  messagesEl.addEventListener('click', handleBubbleActionClick);
3247
+ messagesEl.addEventListener('keydown', handleBubbleActionKeydown);
3080
3248
  messagesEl.addEventListener('click', handleEmbeddedImageClick);
3081
3249
  initThinkingFollow({ getCurrentConversationId: () => currentConvId });
3082
3250
  }
@@ -3114,6 +3282,8 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
3114
3282
  }
3115
3283
  }
3116
3284
  if (isNewNode && insertedNode) {
3285
+ // A reply takes over the board its turn posted (relay-board-view).
3286
+ if (msg?.role === 'assistant') renderRelayBoards();
3117
3287
  syncSeparatorsNow();
3118
3288
  if (scroll) scrollBottom();
3119
3289
  }
@@ -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;