@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
@@ -3,6 +3,12 @@
3
3
  // to the source session) and the "↗ win-test" marker on the sidebar row of a
4
4
  // conversation such an agent created. Both read the `origin` the relay stores
5
5
  // with the message or conversation (docs/plans/2026-09-27-remote-relays.md §4).
6
+ //
7
+ // An agent on THIS relay can start sessions too. Such an origin carries
8
+ // `local: true` and names the conversation the agent runs in: the marker then
9
+ // reads "via agent · “title”" and opens that conversation inside the app
10
+ // (window.openOriginConversation, journal-view.js) instead of linking to
11
+ // another relay. An origin without `local` renders exactly as before.
6
12
 
7
13
  import { remoteConversationUrl } from './remote-relay-shared.mjs';
8
14
 
@@ -19,6 +25,76 @@ function oneLine(value) {
19
25
  return String(value ?? '').replace(/\s+/g, ' ').trim();
20
26
  }
21
27
 
28
+ // How much of the other conversation's title a marker carries; CSS ellipsis
29
+ // takes over where the row is narrower than that.
30
+ export const ORIGIN_TITLE_MAX_SIDEBAR = 28;
31
+ export const ORIGIN_TITLE_MAX = 60;
32
+
33
+ /** "a long conversation tit…": at most `max` characters, one line. */
34
+ export function truncateOriginTitle(value, max = ORIGIN_TITLE_MAX) {
35
+ const text = oneLine(value);
36
+ const limit = Math.max(2, Math.floor(Number(max)) || ORIGIN_TITLE_MAX);
37
+ // By code point, so an emoji is never cut in half.
38
+ const chars = Array.from(text);
39
+ if (chars.length <= limit) return text;
40
+ return `${chars.slice(0, limit - 1).join('').trimEnd()}…`;
41
+ }
42
+
43
+ /** True for the origin of work an agent on this relay started. */
44
+ export function isLocalAgentOrigin(raw) {
45
+ return !!raw && typeof raw === 'object' && !Array.isArray(raw) && raw.local === true;
46
+ }
47
+
48
+ function knownConversation(conversations, id) {
49
+ if (!id || !conversations || typeof conversations !== 'object') return null;
50
+ const found = Object.prototype.hasOwnProperty.call(conversations, id) ? conversations[id] : null;
51
+ return found && typeof found === 'object' ? found : null;
52
+ }
53
+
54
+ /**
55
+ * What a local origin shows, or null when the origin is not a local one.
56
+ * `conversations` is the browser's list by id: the marker is a link only while
57
+ * the orchestrating conversation is in it, and shows that conversation's
58
+ * current title (the origin stores the one it had at creation). `ownId` is the
59
+ * conversation the marker sits on, which never links to itself.
60
+ */
61
+ export function localAgentOriginModel(origin, {
62
+ conversations = null,
63
+ ownId = '',
64
+ linkable = true,
65
+ maxTitleLength = ORIGIN_TITLE_MAX,
66
+ } = {}) {
67
+ if (!isLocalAgentOrigin(origin)) return null;
68
+ const conversationId = oneLine(origin.conversationId);
69
+ const known = conversationId !== oneLine(ownId) ? knownConversation(conversations, conversationId) : null;
70
+ const fullTitle = oneLine(known?.title) || oneLine(origin.conversationTitle);
71
+ const title = truncateOriginTitle(fullTitle, maxTitleLength);
72
+ return {
73
+ conversationId,
74
+ fullTitle,
75
+ title,
76
+ model: oneLine(origin.model),
77
+ text: title ? `via agent · “${title}”` : 'via agent',
78
+ linkable: linkable !== false && !!known,
79
+ };
80
+ }
81
+
82
+ // A button while the orchestrating conversation can be opened, plain text
83
+ // otherwise. `tooltip` is a full sentence.
84
+ function localAgentElementHtml(model, { className, text = model.text, tooltip }) {
85
+ const label = escapeHtml(text);
86
+ if (model.linkable) {
87
+ return `<button type="button" class="${className}" data-origin-conversation-id="${escapeHtml(model.conversationId)}" title="${escapeHtml(`${tooltip} Opens that conversation.`)}" onclick="openOriginConversation(event, this)">${label}</button>`;
88
+ }
89
+ return `<span class="${className}" title="${escapeHtml(tooltip)}">${label}</span>`;
90
+ }
91
+
92
+ function startedByTooltip(model) {
93
+ return model.fullTitle
94
+ ? `Started by an agent in “${model.fullTitle}”.`
95
+ : 'Started by an agent in another conversation on this relay.';
96
+ }
97
+
22
98
  /** The display fields of an `origin`, or null when it names no relay. */
23
99
  export function normalizeRemoteOrigin(raw) {
24
100
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -35,7 +111,9 @@ export function normalizeRemoteOrigin(raw) {
35
111
  };
36
112
  }
37
113
 
38
- export function remoteOriginBadgeText(origin) {
114
+ export function remoteOriginBadgeText(origin, options = {}) {
115
+ const local = localAgentOriginModel(origin, options);
116
+ if (local) return local.model ? `${local.text} · ${local.model}` : local.text;
39
117
  const normalized = normalizeRemoteOrigin(origin);
40
118
  if (!normalized) return '';
41
119
  const parts = [`↗ from ${normalized.relayName}`];
@@ -54,8 +132,21 @@ export function remoteOriginLink(origin) {
54
132
  /**
55
133
  * The badge row that sits above a user bubble's text. `linkable: false` (the
56
134
  * shared read-only view) renders it without the link to the other relay.
135
+ * `conversations` (the browser's list by id) lets a local agent's badge open
136
+ * the conversation that agent runs in.
57
137
  */
58
- export function renderRemoteOriginBadgeHtml(origin, { linkable = true } = {}) {
138
+ export function renderRemoteOriginBadgeHtml(origin, { linkable = true, conversations = null } = {}) {
139
+ const local = localAgentOriginModel(origin, { conversations, linkable });
140
+ if (local) {
141
+ const badge = localAgentElementHtml(local, {
142
+ className: 'msg-origin-badge msg-origin-agent',
143
+ text: remoteOriginBadgeText(origin, { conversations }),
144
+ tooltip: local.fullTitle
145
+ ? `Sent by an agent in “${local.fullTitle}”.`
146
+ : 'Sent by an agent in another conversation on this relay.',
147
+ });
148
+ return `<div class="msg-origin">${badge}</div>`;
149
+ }
59
150
  const normalized = normalizeRemoteOrigin(origin);
60
151
  if (!normalized) return '';
61
152
  const label = escapeHtml(remoteOriginBadgeText(normalized));
@@ -67,10 +158,35 @@ export function renderRemoteOriginBadgeHtml(origin, { linkable = true } = {}) {
67
158
  return `<div class="msg-origin"><span class="msg-origin-badge" title="Sent by an agent on relay ${name}.">${label}</span></div>`;
68
159
  }
69
160
 
70
- /** The sidebar marker for a conversation another relay's agent created. */
71
- export function renderConversationOriginMarkerHtml(conversation) {
161
+ /**
162
+ * The sidebar marker for a conversation an agent created: "↗ win-test" for
163
+ * another relay's agent, "via agent · “title”" for one on this relay.
164
+ */
165
+ export function renderConversationOriginMarkerHtml(conversation, { conversations = null, linkable = true } = {}) {
166
+ const local = localAgentOriginModel(conversation?.origin, {
167
+ conversations,
168
+ linkable,
169
+ ownId: conversation?.id,
170
+ maxTitleLength: ORIGIN_TITLE_MAX_SIDEBAR,
171
+ });
172
+ if (local) {
173
+ return localAgentElementHtml(local, {
174
+ className: 'conv-origin-marker conv-origin-agent',
175
+ tooltip: startedByTooltip(local),
176
+ });
177
+ }
72
178
  const normalized = normalizeRemoteOrigin(conversation?.origin);
73
179
  if (!normalized) return '';
74
180
  const name = escapeHtml(normalized.relayName);
75
181
  return `<span class="conv-origin-marker" title="Started by an agent on relay ${name}">↗ ${name}</span>`;
76
182
  }
183
+
184
+ /**
185
+ * The same marker for the conversation header, with room for more of the
186
+ * title. Only a local origin has one; '' for everything else.
187
+ */
188
+ export function renderConversationHeaderOriginHtml(conversation, { conversations = null, linkable = true } = {}) {
189
+ const local = localAgentOriginModel(conversation?.origin, { conversations, linkable, ownId: conversation?.id });
190
+ if (!local) return '';
191
+ return localAgentElementHtml(local, { className: 'conv-origin-agent', tooltip: startedByTooltip(local) });
192
+ }
@@ -6,6 +6,7 @@ import {
6
6
  WORKSPACE_FILE_EXTENSIONS,
7
7
  serverPlatform,
8
8
  } from './store.js';
9
+ import { scanInlineDollars, splitAtLiteralDollars } from './math-dollars.mjs';
9
10
 
10
11
  export function workspaceMentionRegex() {
11
12
  return /(?:[A-Za-z]:[\\/])?(?:\.{1,2}[\\/])?(?:[A-Za-z0-9._-]+[\\/])*[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,16}/g;
@@ -397,7 +398,8 @@ function normalizeInlineMathDelimiters(line) {
397
398
  let output = '';
398
399
  let index = 0;
399
400
  let inCode = false;
400
- let inDollarMath = false;
401
+ // Only real dollar maths is skipped over; a price's `$` opens nothing.
402
+ const dollarMathEnds = new Map(scanInlineDollars(line).spans);
401
403
 
402
404
  while (index < line.length) {
403
405
  const char = line[index];
@@ -412,15 +414,10 @@ function normalizeInlineMathDelimiters(line) {
412
414
  index += 1;
413
415
  continue;
414
416
  }
415
- if (char === '$' && line[index - 1] !== '\\') {
416
- inDollarMath = !inDollarMath;
417
- output += char;
418
- index += 1;
419
- continue;
420
- }
421
- if (inDollarMath) {
422
- output += char;
423
- index += 1;
417
+ if (char === '$' && dollarMathEnds.has(index)) {
418
+ const closeIndex = dollarMathEnds.get(index);
419
+ output += line.slice(index, closeIndex + 1);
420
+ index = closeIndex + 1;
424
421
  continue;
425
422
  }
426
423
  if (char === '\\' && line[index + 1] === '(') {
@@ -469,6 +466,7 @@ function protectMarkdownEscapesInMath(source) {
469
466
  let output = '';
470
467
  let index = 0;
471
468
  let inCode = false;
469
+ const dollarMathEnds = new Map(scanInlineDollars(line).spans);
472
470
  while (index < line.length) {
473
471
  if (line[index] === '`') {
474
472
  inCode = !inCode;
@@ -482,7 +480,10 @@ function protectMarkdownEscapesInMath(source) {
482
480
  continue;
483
481
  }
484
482
  const delimiter = line[index + 1] === '$' ? '$$' : '$';
485
- const closeIndex = line.indexOf(delimiter, index + delimiter.length);
483
+ // A single `$` that is a plain dollar sign (a price) opens no formula.
484
+ const closeIndex = delimiter === '$'
485
+ ? (dollarMathEnds.has(index) ? dollarMathEnds.get(index) : -1)
486
+ : line.indexOf(delimiter, index + delimiter.length);
486
487
  if (closeIndex < 0) {
487
488
  output += line[index];
488
489
  index += 1;
@@ -550,6 +551,40 @@ export function normalizeMathDelimiters(source) {
550
551
  return protectMarkdownEscapesInMath(normalized.join('\n'));
551
552
  }
552
553
 
554
+ const MATH_IGNORED_TAGS = ['pre', 'code', 'script', 'style', 'textarea', 'option', 'a'];
555
+ const LITERAL_DOLLAR_CLASS = 'literal-dollar';
556
+
557
+ /**
558
+ * KaTeX's auto-render pairs any two `$` in a block, so "$5 … $9" would become
559
+ * one formula. Each plain dollar sign (see math-dollars.mjs) is moved into a
560
+ * span of its own that auto-render skips; with no partner in its text node,
561
+ * it stays a dollar sign.
562
+ */
563
+ function isolateLiteralDollars(container) {
564
+ const ignored = MATH_IGNORED_TAGS.join(',');
565
+ const walker = document.createTreeWalker(container, 4 /* NodeFilter.SHOW_TEXT */);
566
+ const textNodes = [];
567
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
568
+ if (node.nodeValue.includes('$') && !node.parentElement?.closest(ignored)) textNodes.push(node);
569
+ }
570
+ for (const node of textNodes) {
571
+ const parts = splitAtLiteralDollars(node.nodeValue);
572
+ if (!parts.some((part) => part.literal)) continue;
573
+ const fragment = document.createDocumentFragment();
574
+ for (const part of parts) {
575
+ if (!part.literal) {
576
+ fragment.appendChild(document.createTextNode(part.text));
577
+ continue;
578
+ }
579
+ const span = document.createElement('span');
580
+ span.className = LITERAL_DOLLAR_CLASS;
581
+ span.textContent = part.text;
582
+ fragment.appendChild(span);
583
+ }
584
+ node.parentNode.replaceChild(fragment, node);
585
+ }
586
+ }
587
+
553
588
  function renderMathInHtml(html) {
554
589
  const renderMathInElement = globalThis.renderMathInElement;
555
590
  if (typeof renderMathInElement !== 'function' || !globalThis.document?.createElement) return html;
@@ -557,6 +592,7 @@ function renderMathInHtml(html) {
557
592
  const container = document.createElement('div');
558
593
  container.innerHTML = html;
559
594
  try {
595
+ isolateLiteralDollars(container);
560
596
  renderMathInElement(container, {
561
597
  delimiters: [
562
598
  { left: '$$', right: '$$', display: true },
@@ -564,7 +600,8 @@ function renderMathInHtml(html) {
564
600
  { left: '\\(', right: '\\)', display: false },
565
601
  { left: '$', right: '$', display: false },
566
602
  ],
567
- ignoredTags: ['pre', 'code', 'script', 'style', 'textarea', 'option', 'a'],
603
+ ignoredTags: MATH_IGNORED_TAGS,
604
+ ignoredClasses: [LITERAL_DOLLAR_CLASS],
568
605
  throwOnError: false,
569
606
  strict: 'ignore',
570
607
  trust: false,
@@ -41,9 +41,11 @@ import { refreshPushSettingsSection } from './push-settings.js';
41
41
  import { selectSettingsTab } from './settings-tabs.js';
42
42
  import { refreshClaudeAuthSection } from './claude-auth-ui.js';
43
43
  import { refreshGrokAuthSection } from './grok-auth-ui.js';
44
+ import { openClaudeCloudSettingsSection } from './claude-cloud-settings-ui.js';
44
45
  import { refreshCliInstallSections } from './cli-install-ui.js';
45
46
  import { refreshUpdateSection } from './update-ui.js';
46
47
  import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
48
+ import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
47
49
  import {
48
50
  getPreviews,
49
51
  renderPreviewRowsInto,
@@ -437,7 +439,7 @@ function ensureClaudeSettingsInputTracking() {
437
439
  }
438
440
 
439
441
  function setClaudeSettingsControlsDisabled(disabled) {
440
- for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-save-btn']) {
442
+ for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-save-btn']) {
441
443
  const element = document.getElementById(id);
442
444
  if (element) element.disabled = disabled;
443
445
  }
@@ -450,10 +452,15 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
450
452
  enabled: settings?.enabled === true,
451
453
  model: String(settings?.model || claudeSettingsState.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
452
454
  models: Array.isArray(settings?.models) ? settings.models : claudeSettingsState.models,
455
+ attributionMode: ['oar', 'vanilla', 'off'].includes(settings?.attributionMode) ? settings.attributionMode : (claudeSettingsState.attributionMode || 'oar'),
456
+ attributionExample: String(settings?.attributionExample || claudeSettingsState.attributionExample || '').trim(),
453
457
  };
454
458
  const modelInput = document.getElementById('claude-model-input');
455
459
  const toggle = document.getElementById('claude-enabled-toggle');
456
460
  const status = document.getElementById('claude-settings-status');
461
+ const attributionSelect = document.getElementById('claude-attribution-select');
462
+ if (attributionSelect && (!claudeSettingsInputsDirty || resetInputs)) attributionSelect.value = claudeSettingsState.attributionMode;
463
+ renderClaudeAttributionExample(attributionSelect?.value || claudeSettingsState.attributionMode);
457
464
  if (modelInput && (!claudeSettingsInputsDirty || resetInputs)) modelInput.value = claudeSettingsState.model;
458
465
  if (resetInputs) claudeSettingsInputsDirty = false;
459
466
  if (toggle) {
@@ -486,14 +493,36 @@ async function syncClaudeSettingsInputs() {
486
493
  }
487
494
  }
488
495
 
496
+ // What commits will carry for the chosen mode: the example trailer the relay
497
+ // rendered for the default model, Claude Code's own line, or nothing.
498
+ export function claudeAttributionExampleText(mode, example = '') {
499
+ if (mode === 'vanilla') return 'Commits keep Claude Code\'s own attribution (Co-Authored-By: Claude … <noreply@anthropic.com>).';
500
+ if (mode === 'off') return 'Commits and pull requests carry no attribution line.';
501
+ const trailer = String(example || '').trim();
502
+ return trailer ? `Commits end with: ${trailer}` : 'Commits end with: Co-authored-by: Open Agent Relay (<model>) <no-reply@oar.sh>';
503
+ }
504
+
505
+ function renderClaudeAttributionExample(mode) {
506
+ const example = document.getElementById('claude-attribution-example');
507
+ if (!example) return;
508
+ example.textContent = claudeAttributionExampleText(mode, claudeSettingsState.attributionExample);
509
+ }
510
+
511
+ window.previewClaudeAttribution = (mode) => {
512
+ claudeSettingsInputsDirty = true;
513
+ renderClaudeAttributionExample(mode);
514
+ };
515
+
489
516
  export async function saveClaudeSettings() {
490
517
  if (claudeSettingsUpdateInFlight) return;
491
518
  const modelInput = document.getElementById('claude-model-input');
492
519
  const model = String(modelInput?.value || '').trim() || 'claude-sonnet-5';
520
+ const attributionSelect = document.getElementById('claude-attribution-select');
521
+ const attributionMode = ['oar', 'vanilla', 'off'].includes(attributionSelect?.value) ? attributionSelect.value : undefined;
493
522
  claudeSettingsUpdateInFlight = true;
494
523
  setClaudeSettingsControlsDisabled(true);
495
524
  try {
496
- const result = await updateClaudeSettings({ model });
525
+ const result = await updateClaudeSettings({ model, attributionMode });
497
526
  if (!result) throw new Error('Failed to save Claude settings.');
498
527
  applyClaudeSettingsState(result, { resetInputs: true });
499
528
  showTransientRelayNotice(
@@ -1655,6 +1684,7 @@ export function openSettingsModal(tab, providerTab) {
1655
1684
  ensureClaudeSettingsInputTracking();
1656
1685
  void syncClaudeSettingsInputs();
1657
1686
  void refreshClaudeAuthSection();
1687
+ void openClaudeCloudSettingsSection();
1658
1688
  void refreshGrokAuthSection();
1659
1689
  // Forced: the cached rows can be 30s stale, and a CLI installed from a shell
1660
1690
  // in the meantime should show up the moment the panel opens. Sockets keep it
@@ -1678,6 +1708,7 @@ export function openSettingsModal(tab, providerTab) {
1678
1708
  void refreshPushSettingsSection();
1679
1709
  void refreshPreviewsSection();
1680
1710
  void refreshRemoteRelaysSection();
1711
+ void refreshAgentSessionsSection();
1681
1712
  void refreshFeatureFlagsSection();
1682
1713
  void refreshUpdateSection();
1683
1714
  modal?.classList.add('visible');
@@ -4,7 +4,7 @@ const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
4
4
  const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
- const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'grok', 'cursor'];
7
+ const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor'];
8
8
 
9
9
  let settingsTabsBound = false;
10
10
 
@@ -30,6 +30,8 @@ import {
30
30
  setConversationWatcherCount,
31
31
  setUpdateState,
32
32
  applySessionWorkerSteering,
33
+ refreshSummaryModal,
34
+ summaryModalState,
33
35
  } from './store.js';
34
36
  import { scheduleContextUsageRefresh } from './api-client.js';
35
37
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
@@ -44,11 +46,16 @@ import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
44
46
  import { setPreviews } from './preview-cards.mjs';
45
47
  import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
46
48
  import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
49
+ import { AGENT_SESSIONS_SOCKET_EVENT } from './agent-sessions-settings.mjs';
50
+ import { applyAgentSessionsSettingsState, refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
47
51
  import { applyClaudeAuthState } from './claude-auth-ui.js';
48
52
  import { applyGrokAuthState } from './grok-auth-ui.js';
53
+ import { applyClaudeCloudSettingsState, refreshClaudeCloudSettingsState } from './claude-cloud-settings-ui.js';
54
+ import { applyClaudeCloudSessionEvent } from './claude-cloud-conversation-ui.js';
49
55
  import { applyCliInstallState } from './cli-install-ui.js';
50
56
  import { renderUpdateSection } from './update-ui.js';
51
57
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
58
+ import { applyClaudeUsageLimit, applyUsageLimitPause } from './usage-limit-ui.js';
52
59
  import {
53
60
  showThinking,
54
61
  removeThinking,
@@ -64,6 +71,7 @@ import {
64
71
  applyConversationTurnStatus,
65
72
  renderMessages,
66
73
  appendMessage,
74
+ replaceRenderedMessage,
67
75
  applyIncomingConversationDraftUpdate,
68
76
  getRenderedConversationMessageFingerprints,
69
77
  clearBubbleCancelState,
@@ -399,6 +407,26 @@ export async function connectSocket(overrideDeps) {
399
407
  socket.on('relay_shutdown_state', (payload) => {
400
408
  applyRelayShutdownState(payload || null);
401
409
  });
410
+ // The Claude usage limit: a conversation's paused turn, and the account's
411
+ // latest report (also sent on connect).
412
+ socket.on('usage_limit_pause', (payload) => {
413
+ applyUsageLimitPause(payload || null);
414
+ });
415
+ socket.on('claude_usage_limit', (payload) => {
416
+ applyClaudeUsageLimit(payload || null);
417
+ });
418
+ // Claude Cloud settings (enable, default model, environment). The event
419
+ // carries the same shape as GET /api/settings/claude-cloud; one without a
420
+ // body is read as "something changed" and answered with a fresh read.
421
+ socket.on('claude_cloud_settings_updated', (payload) => {
422
+ if (payload && typeof payload === 'object') applyClaudeCloudSettingsState(payload);
423
+ else void refreshClaudeCloudSettingsState();
424
+ });
425
+ // A cloud session got its id, pushed a branch or reported a new cost: the
426
+ // conversation's `cloud` field changed, and with it the cloud line.
427
+ socket.on('claude_cloud_session', (payload) => {
428
+ if (applyClaudeCloudSessionEvent(payload || null)) deps?.syncChatTitleControls?.();
429
+ });
402
430
  socket.on('grok_settings_updated', (payload) => {
403
431
  deps?.applyGrokSettingsState?.(payload || {});
404
432
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
@@ -489,6 +517,20 @@ export async function connectSocket(overrideDeps) {
489
517
  if (sourceMessageId) clearRelayStreamStateForMessage(sourceMessageId);
490
518
  refreshSessionWorkerStatus().catch(() => {});
491
519
  });
520
+ // A folder's commit attribution changed (any client): an open context modal
521
+ // re-reads it so its row and effect text match.
522
+ socket.on('workspace_root_attribution_updated', () => {
523
+ if (summaryModalState.kind === 'context') refreshSummaryModal().catch(() => {});
524
+ });
525
+ // A message the relay rewrote in place (the note while a session's worker
526
+ // cannot be started): same id, new text or kind. Not on the page yet (the
527
+ // conversation was opened after it was written): appended like any other.
528
+ socket.on('message_updated', ({ conversationId, messageId, message }) => {
529
+ if (!messageId || conversationId !== currentConvId || !message) return;
530
+ if (!replaceRenderedMessage(message, messageId)) {
531
+ appendMessage(message, isMessagesAtBottom(), messageId, false);
532
+ }
533
+ });
492
534
  socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
493
535
  socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
494
536
  socket.on('relay_question_changed', () => {
@@ -512,6 +554,13 @@ export async function connectSocket(overrideDeps) {
512
554
  socket.on(REMOTE_RELAY_SOCKET_EVENT, (payload) => {
513
555
  setRemoteRelaysSnapshot(payload || null);
514
556
  });
557
+ // Agent sessions (Settings → Relays): the event carries the same shape as
558
+ // GET /api/settings/agent-sessions; one without a body is answered with a
559
+ // fresh read.
560
+ socket.on(AGENT_SESSIONS_SOCKET_EVENT, (payload) => {
561
+ if (payload && typeof payload === 'object') applyAgentSessionsSettingsState(payload);
562
+ else void refreshAgentSessionsSection();
563
+ });
515
564
  socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
516
565
  if (!messageId || !text) return;
517
566
  const entry = {
@@ -728,6 +777,17 @@ export async function connectSocket(overrideDeps) {
728
777
  }
729
778
  renderConvList();
730
779
  });
780
+ socket.on('conversation_archived', ({ conversationId, archived }) => {
781
+ const id = String(conversationId || '').trim();
782
+ if (!id) return;
783
+ const conversation = conversations[id];
784
+ if (conversation) conversation.archived = archived !== false;
785
+ renderConvList();
786
+ if (currentConvId === id) {
787
+ syncChatTitleControls();
788
+ syncComposerButtonState();
789
+ }
790
+ });
731
791
  socket.on('conversation_deleted', ({ conversationId }) => {
732
792
  delete conversations[conversationId];
733
793
  for (const [id, question] of relayQuestions.entries()) {
@@ -3,6 +3,8 @@ import { recordCliLifecycleEvent, recordRelayLifecycleEvent } from './status-sto
3
3
  import { resolveRelayDotState } from './relay-dot-state.mjs';
4
4
  import { readRepoBrowserPreferences } from './repo-browser-preferences.mjs';
5
5
  import { isChatInteractionHeld } from './selection-guard.mjs';
6
+ import { nextSubagentStatus } from './subagent-fold.mjs';
7
+ import { useStrictStrikethrough } from './markdown-strikethrough.mjs';
6
8
 
7
9
  function resolveAppBase() {
8
10
  const configuredBase = typeof window.__COPILOT_APP_CONFIG?.basePath === 'string'
@@ -173,6 +175,7 @@ const CONVERSATION_SCROLL_STORAGE_PREFIX = 'copilot_message_scroll_';
173
175
 
174
176
  if (globalThis.marked && typeof globalThis.marked.setOptions === 'function') {
175
177
  globalThis.marked.setOptions({ breaks: true });
178
+ useStrictStrikethrough(globalThis.marked);
176
179
  } else {
177
180
  console.warn('[store] marked unavailable; markdown rendering will use plain-text fallback.');
178
181
  }
@@ -1475,7 +1478,7 @@ export function upsertSubagentRun(payload) {
1475
1478
  conversationId: String(payload?.conversationId || existing?.conversationId || '').trim() || null,
1476
1479
  parentSubagentId: normalizeSubagentRunId(payload?.parentSubagentId) || existing?.parentSubagentId || null,
1477
1480
  displayName: String(payload?.displayName || existing?.displayName || '').trim() || null,
1478
- status: normalizeSubagentStatus(payload?.status) || existing?.status || 'running',
1481
+ status: nextSubagentStatus({ reported: payload?.status, known: existing?.status, normalize: normalizeSubagentStatus }),
1479
1482
  startedAt: existing?.startedAt || payload?.timestamp || now,
1480
1483
  updatedAt: payload?.timestamp || now,
1481
1484
  activities: existing?.activities || [],
@@ -0,0 +1,61 @@
1
+ // Whether a live subagent bubble is shown folded (header only), and what the
2
+ // header says while it is.
3
+ //
4
+ // A running turn can hold many subagents, and each keeps its whole transcript
5
+ // in its bubble: on a phone that is screens of text to scroll past to reach
6
+ // the turn's own output. So a bubble is folded from the start and stays folded
7
+ // until the user opens it; what they chose by hand holds, in either direction,
8
+ // for as long as the bubble lives. The folded header makes up for it with one
9
+ // line: how many steps the run has taken and the latest one.
10
+
11
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'dropped', 'done']);
12
+
13
+ export function isTerminalSubagentStatus(status) {
14
+ return TERMINAL_STATUSES.has(String(status || '').trim().toLowerCase());
15
+ }
16
+
17
+ /**
18
+ * `choice` is the user's own: 'open', 'folded', or nothing.
19
+ */
20
+ export function resolveSubagentFold({ choice = null } = {}) {
21
+ return choice !== 'open';
22
+ }
23
+
24
+ /** The choice a tap on the header records, given what the bubble shows now. */
25
+ export function toggledSubagentFoldChoice(folded) {
26
+ return folded ? 'open' : 'folded';
27
+ }
28
+
29
+ const SUMMARY_MAX_CHARS = 160;
30
+
31
+ function oneLine(text) {
32
+ return String(text || '').replace(/\s+/g, ' ').trim();
33
+ }
34
+
35
+ /**
36
+ * The line a folded header shows: the number of steps (activity lines) and
37
+ * the latest one, or the end of what the run has written when it has taken
38
+ * no step yet. Empty when there is nothing to say.
39
+ */
40
+ export function subagentFoldSummary({ activities = [], streamText = '' } = {}) {
41
+ const steps = (Array.isArray(activities) ? activities : []).map(oneLine).filter(Boolean);
42
+ if (steps.length) {
43
+ const latest = steps[steps.length - 1];
44
+ const count = steps.length === 1 ? '1 step' : `${steps.length} steps`;
45
+ return `${count} · ${latest}`.slice(0, SUMMARY_MAX_CHARS);
46
+ }
47
+ const lines = String(streamText || '').split(/\r?\n/).map(oneLine).filter(Boolean);
48
+ return lines.length ? lines[lines.length - 1].slice(0, SUMMARY_MAX_CHARS) : '';
49
+ }
50
+
51
+ /**
52
+ * The status a run has after an update. Most updates report none (an
53
+ * activity line, a stream frame, a restored snapshot's activity list), and
54
+ * those must leave the status alone: reading "no status" as "running" turned
55
+ * every finished run back into a running one on the next line of any kind.
56
+ */
57
+ export function nextSubagentStatus({ reported, known, normalize = (value) => value } = {}) {
58
+ const text = String(reported || '').trim();
59
+ if (text) return normalize(text);
60
+ return String(known || '').trim() || 'running';
61
+ }