@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,31 @@
1
+ // Strikethrough needs two tildes.
2
+ //
3
+ // The Markdown library also strikes text between two SINGLE tildes. In prose
4
+ // a single tilde means "about" ("~35 s … ~7 min"), and two of them in one
5
+ // paragraph struck out everything in between. Only `~~text~~` strikes now;
6
+ // a single tilde is the character it is.
7
+
8
+ const DOUBLE_TILDE = /^~~(?=\S)([\s\S]*?\S)~~(?!~)/;
9
+
10
+ /**
11
+ * The `del` tokenizer for `marked.use({ tokenizer: … })`. Returns the token
12
+ * for `~~text~~`, and undefined for anything else: "no strikethrough here".
13
+ * (`false` would hand the text back to the library's own rule.)
14
+ */
15
+ export function strictStrikethroughTokenizer(src) {
16
+ const match = DOUBLE_TILDE.exec(String(src || ''));
17
+ if (!match) return undefined;
18
+ return {
19
+ type: 'del',
20
+ raw: match[0],
21
+ text: match[1],
22
+ tokens: this.lexer.inlineTokens(match[1]),
23
+ };
24
+ }
25
+
26
+ /** Installs the rule on a Markdown library; false when it cannot take one. */
27
+ export function useStrictStrikethrough(markdown) {
28
+ if (!markdown || typeof markdown.use !== 'function') return false;
29
+ markdown.use({ tokenizer: { del: strictStrikethroughTokenizer } });
30
+ return true;
31
+ }
@@ -0,0 +1,89 @@
1
+ // Which `$` in a run of text delimit inline maths, and which are plain dollar
2
+ // signs. Prices are the common case of the second kind: without a rule,
3
+ // "$5 for one and $9 for two" reads as one formula running from the first
4
+ // sign to the second.
5
+ //
6
+ // The rule is the one pandoc uses for dollar maths: the opening `$` has a
7
+ // non-space character directly after it, the closing `$` is the next dollar
8
+ // sign, has a non-space character directly before it and no digit directly
9
+ // after it. A `$$` pair is
10
+ // display maths and is left alone, and a `$` behind a backslash is escaped.
11
+
12
+ const TEX_LOOKING = /[\\^_{}=]/;
13
+
14
+ function isEscaped(text, index) {
15
+ let backslashes = 0;
16
+ for (let i = index - 1; i >= 0 && text[i] === '\\'; i -= 1) backslashes += 1;
17
+ return backslashes % 2 === 1;
18
+ }
19
+
20
+ function isSpace(char) {
21
+ return char === undefined || /\s/.test(char);
22
+ }
23
+
24
+ // A formula holds no dollar sign of its own, so only the next one can close
25
+ // it. When that one does not qualify ("$5, and $x^2$": it follows a space),
26
+ // the opening sign is a plain dollar and the next one gets its own turn.
27
+ function findClosingDollar(text, openIndex) {
28
+ let i = openIndex + 1;
29
+ while (i < text.length && (text[i] !== '$' || isEscaped(text, i))) i += 1;
30
+ if (i >= text.length) return -1;
31
+ // Part of a `$$`: not the end of an inline formula.
32
+ if (text[i + 1] === '$') return -1;
33
+ if (isSpace(text[i - 1])) return -1;
34
+ const content = text.slice(openIndex + 1, i);
35
+ // "$5/$10": a digit right behind the sign means the sign starts a number.
36
+ // Real maths directly followed by a digit ("$x^2$3") is told apart by
37
+ // looking like TeX.
38
+ if (/\d/.test(text[i + 1] || '') && !TEX_LOOKING.test(content)) return -1;
39
+ return i;
40
+ }
41
+
42
+ /**
43
+ * Scan `text` for single-dollar maths.
44
+ * @returns {{ spans: Array<[number, number]>, literals: number[] }} `spans`
45
+ * are [open, close] index pairs of formulas, `literals` the indexes of `$`
46
+ * that are plain dollar signs.
47
+ */
48
+ export function scanInlineDollars(text) {
49
+ const source = String(text ?? '');
50
+ const spans = [];
51
+ const literals = [];
52
+ let index = 0;
53
+ while (index < source.length) {
54
+ if (source[index] !== '$' || isEscaped(source, index)) {
55
+ index += 1;
56
+ continue;
57
+ }
58
+ if (source[index + 1] === '$') {
59
+ const close = source.indexOf('$$', index + 2);
60
+ index = close < 0 ? index + 2 : close + 2;
61
+ continue;
62
+ }
63
+ const close = isSpace(source[index + 1]) ? -1 : findClosingDollar(source, index);
64
+ if (close < 0) {
65
+ literals.push(index);
66
+ index += 1;
67
+ continue;
68
+ }
69
+ spans.push([index, close]);
70
+ index = close + 1;
71
+ }
72
+ return { spans, literals };
73
+ }
74
+
75
+ /** `text` cut at its plain dollar signs: `[{ text, literal }]`, in order. */
76
+ export function splitAtLiteralDollars(text) {
77
+ const source = String(text ?? '');
78
+ const { literals } = scanInlineDollars(source);
79
+ if (!literals.length) return [{ text: source, literal: false }];
80
+ const parts = [];
81
+ let from = 0;
82
+ for (const index of literals) {
83
+ if (index > from) parts.push({ text: source.slice(from, index), literal: false });
84
+ parts.push({ text: '$', literal: true });
85
+ from = index + 1;
86
+ }
87
+ if (from < source.length) parts.push({ text: source.slice(from), literal: false });
88
+ return parts;
89
+ }
@@ -12,7 +12,7 @@ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {})
12
12
  const provider = String(providerType || '').trim().toLowerCase();
13
13
  const id = String(modelId || '').trim().toLowerCase().replace(/\[[^\]]*\]$/, '');
14
14
  const providerFallback = () => {
15
- if (provider === 'claude') return 'Message Claude…';
15
+ if (provider === 'claude' || provider === 'claude-cloud') return 'Message Claude…';
16
16
  if (provider === 'grok') return 'Message Grok…';
17
17
  if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
18
18
  if (provider === 'cursor') return 'Message Cursor…';
@@ -32,6 +32,7 @@ const SOURCE_LABELS = Object.freeze(Object.assign(Object.create(null), {
32
32
  const TAB_LABELS = Object.freeze(Object.assign(Object.create(null), {
33
33
  github: 'Copilot',
34
34
  claude: 'Claude',
35
+ 'claude-cloud': 'Claude Cloud',
35
36
  cursor: 'Cursor',
36
37
  grok: 'Grok',
37
38
  }));
@@ -96,26 +97,32 @@ export function formatAmount(value, unit) {
96
97
  return String(Math.round(n * 100) / 100);
97
98
  }
98
99
 
99
- export function formatResetCountdown(resetAt, now = new Date()) {
100
+ /**
101
+ * @param {object} [options]
102
+ * @param {boolean} [options.expiry] the date is when the allowance ends for
103
+ * good (a credit), so it reads "expires …" instead of "resets …"
104
+ */
105
+ export function formatResetCountdown(resetAt, now = new Date(), { expiry = false } = {}) {
100
106
  if (!resetAt) return null;
101
107
  const target = new Date(resetAt);
102
108
  if (Number.isNaN(target.getTime())) return null;
103
109
  const reference = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
110
+ const verb = expiry ? 'expires' : 'resets';
104
111
  const diffMs = target.getTime() - reference.getTime();
105
- if (diffMs <= 0) return 'resets now';
112
+ if (diffMs <= 0) return expiry ? 'expired' : 'resets now';
106
113
  const minutes = Math.floor(diffMs / 60000);
107
- if (minutes < 60) return `resets in ${Math.max(1, minutes)} min`;
114
+ if (minutes < 60) return `${verb} in ${Math.max(1, minutes)} min`;
108
115
  const hours = Math.floor(minutes / 60);
109
116
  if (hours < 24) {
110
117
  const remainderMinutes = minutes % 60;
111
- return remainderMinutes ? `resets in ${hours} h ${remainderMinutes} min` : `resets in ${hours} h`;
118
+ return remainderMinutes ? `${verb} in ${hours} h ${remainderMinutes} min` : `${verb} in ${hours} h`;
112
119
  }
113
120
  const days = Math.floor(hours / 24);
114
121
  if (days <= 14) {
115
122
  const remainderHours = hours % 24;
116
- return remainderHours ? `resets in ${days} d ${remainderHours} h` : `resets in ${days} d`;
123
+ return remainderHours ? `${verb} in ${days} d ${remainderHours} h` : `${verb} in ${days} d`;
117
124
  }
118
- return `resets ${target.toISOString().slice(0, 10)}`;
125
+ return `${verb} ${target.toISOString().slice(0, 10)}`;
119
126
  }
120
127
 
121
128
  /**
@@ -153,12 +160,17 @@ function renderMeter(meter, now) {
153
160
  const width = meter.unlimited ? 100 : Math.max(0, Math.min(100, utilization ?? 0));
154
161
  const color = meter.unlimited ? '#3fb950' : utilizationColor(utilization);
155
162
  const summary = meterSummaryText(meter);
156
- const countdown = formatResetCountdown(meter.resetAt, now);
163
+ const countdown = formatResetCountdown(meter.resetAt, now, { expiry: meter.resetKind === 'expiry' });
157
164
  const percentLabel = meter.unlimited
158
165
  ? '∞'
159
166
  : (utilization === null ? '—' : `${Math.round(utilization)}%`);
167
+ // The provider's own severity word; "normal" is the absence of a warning.
168
+ const severity = String(meter.severity ?? '').trim().toLowerCase();
160
169
  const flags = [
161
170
  meter.estimated ? '<span class="plan-usage-flag">estimated</span>' : '',
171
+ severity && severity !== 'normal'
172
+ ? `<span class="plan-usage-flag plan-usage-flag-severity" data-severity="${escapeHtml(severity)}">${escapeHtml(severity.replace(/[_-]+/g, ' '))}</span>`
173
+ : '',
162
174
  ].filter(Boolean).join('');
163
175
  const meta = [summary, countdown].filter(Boolean).join(' · ');
164
176
 
@@ -193,6 +205,19 @@ function renderDetailSection(section) {
193
205
  `;
194
206
  }
195
207
 
208
+ /** A line of text on the card itself, with an optional link behind it. */
209
+ function renderNote(note) {
210
+ const text = String(note?.text ?? '').trim();
211
+ if (!text) return '';
212
+ const url = String(note?.link?.url ?? '').trim();
213
+ const label = String(note?.link?.label ?? '').trim();
214
+ // Same rule as the server's: only an absolute https link becomes an href.
215
+ const link = label && /^https:\/\//i.test(url)
216
+ ? ` <a class="plan-usage-link" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
217
+ : '';
218
+ return `<div class="plan-usage-detail-note plan-usage-note" data-note-id="${escapeHtml(note?.id ?? '')}">${escapeHtml(text)}${link}</div>`;
219
+ }
220
+
196
221
  /**
197
222
  * The signed-in account under the card title. Three distinct states, because
198
223
  * "we could not ask" must not be drawn as "you are logged out": no `account`
@@ -218,6 +243,7 @@ function renderAccountLine(account) {
218
243
  function renderCard(card, now, { active = true } = {}) {
219
244
  const meters = Array.isArray(card.meters) ? card.meters : [];
220
245
  const details = Array.isArray(card.details) ? card.details : [];
246
+ const notes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : [];
221
247
  const links = Array.isArray(card.links) ? card.links : [];
222
248
 
223
249
  const statusBadge = STATUS_BADGES[card.status];
@@ -255,6 +281,7 @@ function renderCard(card, now, { active = true } = {}) {
255
281
  </header>
256
282
  ${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
257
283
  ${metersHtml}
284
+ ${notes.map(renderNote).join('')}
258
285
  ${details.map(renderDetailSection).join('')}
259
286
  <div class="plan-usage-card-foot">
260
287
  ${capturedLabel ? `<span class="plan-usage-captured">${capturedLabel}</span>` : ''}
@@ -296,8 +323,9 @@ export function planUsageSubtitle(report) {
296
323
  if (!providers.length) return 'No usage data available';
297
324
  const available = providers.filter((card) => Array.isArray(card.meters) && card.meters.length);
298
325
  if (!available.length) return 'No plan limits reported yet';
326
+ // A credit's expiry is not a reset: nothing refills on that date.
299
327
  const soonest = available
300
- .flatMap((card) => card.meters.map((meter) => meter.resetAt))
328
+ .flatMap((card) => card.meters.map((meter) => (meter?.resetKind === 'expiry' ? null : meter?.resetAt)))
301
329
  .filter(Boolean)
302
330
  .sort()[0];
303
331
  const countdown = formatResetCountdown(soonest);
@@ -0,0 +1,70 @@
1
+ // A plan board inside the reply it belongs to.
2
+ //
3
+ // A board used to be a card of its own at the end of the transcript, below
4
+ // whatever was said after it, and it vanished once an action was chosen. It
5
+ // belongs to one turn: its content reads as part of that turn's reply, and its
6
+ // buttons are what the reply asks for. So it is shown inside the reply's
7
+ // bubble (inside the live bubble while the turn still runs), it stays there
8
+ // after the choice, and it says what was chosen.
9
+ //
10
+ // The reply often IS the plan: the worker posts the reply's own text as the
11
+ // board when the agent wrote its plan as the answer. Then the text is shown
12
+ // once, as the reply, and the board adds only its buttons.
13
+
14
+ function plain(text) {
15
+ return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
16
+ }
17
+
18
+ /**
19
+ * Whether the reply already says what the board says. Both are compared as
20
+ * the text a reader sees (rendered, whitespace collapsed), so markdown that
21
+ * renders the same counts as the same.
22
+ */
23
+ export function boardBodyShownInReply(bodyText, replyText) {
24
+ const body = plain(bodyText);
25
+ if (!body) return true;
26
+ return plain(replyText).includes(body);
27
+ }
28
+
29
+ /** The label of the action that was chosen, as the button said it. */
30
+ export function chosenActionLabel(board) {
31
+ const chosen = String(board?.selectedAction || '').trim().toLowerCase();
32
+ if (!chosen) return '';
33
+ const actions = Array.isArray(board?.actions) ? board.actions : [];
34
+ const action = actions.find((item) => String(item?.id || '').trim().toLowerCase() === chosen);
35
+ return String(action?.label || '').trim() || chosen.replace(/[_-]+/g, ' ');
36
+ }
37
+
38
+ /** The line that replaces the buttons once the board is settled; '' while it is pending. */
39
+ export function boardOutcomeLine(board) {
40
+ const status = String(board?.status || 'pending').trim().toLowerCase();
41
+ if (status === 'pending') return '';
42
+ const label = chosenActionLabel(board);
43
+ if (label) return `Chosen: ${label}`;
44
+ return status === 'dismissed' ? 'Dismissed' : 'Closed';
45
+ }
46
+
47
+ /** The boards of one conversation, oldest first, each id once. */
48
+ export function boardsOfConversation(boards, conversationId) {
49
+ const id = String(conversationId || '').trim();
50
+ if (!id) return [];
51
+ const seen = new Set();
52
+ return Array.from(boards || [])
53
+ .filter((board) => board && board.id && board.conversationId === id)
54
+ .filter((board) => (seen.has(board.id) ? false : seen.add(board.id)))
55
+ .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
56
+ }
57
+
58
+ /** What a rendered board depends on: a change in any of it redraws the board. */
59
+ export function boardRenderKey(board, { duplicate = false } = {}) {
60
+ return JSON.stringify([
61
+ board?.id || '',
62
+ board?.status || '',
63
+ board?.selectedAction || '',
64
+ board?.title || '',
65
+ board?.body || '',
66
+ (Array.isArray(board?.actions) ? board.actions : []).map((action) => `${action?.id || ''}:${action?.label || ''}`),
67
+ board?.recommendedAction || '',
68
+ duplicate,
69
+ ]);
70
+ }
@@ -1,9 +1,15 @@
1
1
  import {
2
+ IS_SHARED_VIEW,
2
3
  currentConvId,
3
4
  escHtml,
4
- fmtDate,
5
5
  relayBoards,
6
6
  } from './store.js';
7
+ import {
8
+ boardBodyShownInReply,
9
+ boardOutcomeLine,
10
+ boardRenderKey,
11
+ boardsOfConversation,
12
+ } from './relay-board-inline.mjs';
7
13
  import {
8
14
  loadRelayBoards as loadRelayBoardsApi,
9
15
  submitRelayBoardAction as submitRelayBoardActionApi,
@@ -11,7 +17,10 @@ import {
11
17
  import { renderMarkdownPreview } from './router.js';
12
18
  import { attachCodeCopyButtons } from './code-copy.mjs';
13
19
 
14
- let relayBoardRenderHash = '';
20
+ // The conversation whose settled boards (acted on, dismissed) are loaded. They
21
+ // are asked for per conversation: only the pending ones matter everywhere.
22
+ let boardHistoryLoadedFor = '';
23
+ let boardHistoryLoading = '';
15
24
 
16
25
  export function upsertRelayBoard(board) {
17
26
  if (!board || !board.id) return;
@@ -24,91 +33,178 @@ export async function loadRelayBoards() {
24
33
  const pendingRes = await loadRelayBoardsApi('pending');
25
34
  if (!pendingRes) return;
26
35
  const pending = Array.isArray(pendingRes?.boards) ? pendingRes.boards.filter((board) => board && board.id) : [];
27
- relayBoards.clear();
36
+ // The settled boards of the conversation on screen stay: they are part of
37
+ // its transcript, and this list does not carry them.
38
+ for (const [id, board] of relayBoards.entries()) {
39
+ if (board?.status === 'pending' || board?.conversationId !== currentConvId) relayBoards.delete(id);
40
+ }
28
41
  for (const board of pending) relayBoards.set(board.id, board);
29
42
  window.renderConvList?.();
30
43
  renderRelayBoards();
31
44
  }
32
45
 
46
+ async function loadBoardHistory(conversationId) {
47
+ if (IS_SHARED_VIEW || !conversationId || boardHistoryLoading === conversationId) return;
48
+ boardHistoryLoading = conversationId;
49
+ try {
50
+ const res = await loadRelayBoardsApi('all', conversationId);
51
+ if (!res || currentConvId !== conversationId) return;
52
+ for (const board of Array.isArray(res.boards) ? res.boards : []) {
53
+ if (board && board.id) relayBoards.set(board.id, board);
54
+ }
55
+ boardHistoryLoadedFor = conversationId;
56
+ renderRelayBoards();
57
+ } finally {
58
+ if (boardHistoryLoading === conversationId) boardHistoryLoading = '';
59
+ }
60
+ }
61
+
62
+ /** Where a board is shown: its turn's reply, the live bubble of its turn, or nowhere yet. */
63
+ function findBoardHost(el, board) {
64
+ const source = String(board?.messageId || board?.queueId || '').trim();
65
+ if (!source) return null;
66
+ const replies = el.querySelectorAll(`.msg.assistant[data-source-message-id="${CSS.escape(source)}"]`);
67
+ // The turn's own reply is the last row that answers the message; a steer's
68
+ // marker above it is not a reply.
69
+ for (let index = replies.length - 1; index >= 0; index -= 1) {
70
+ const bubble = replies[index].querySelector(':scope > .msg-bubble');
71
+ if (bubble && !replies[index].classList.contains('relay-board-container')) return { bubble, live: false };
72
+ }
73
+ const live = document.getElementById('thinking-indicator');
74
+ if (live && String(live.dataset.messageId || '') === source) {
75
+ const bubble = live.querySelector(':scope > .thinking-bubble');
76
+ if (bubble) return { bubble, live: true };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function renderedText(html) {
82
+ const probe = document.createElement('div');
83
+ probe.innerHTML = html;
84
+ return probe.textContent || '';
85
+ }
86
+
87
+ /** What the bubble says without the board in it. */
88
+ function bubbleTextWithoutBoards(bubble) {
89
+ const copy = bubble.cloneNode(true);
90
+ copy.querySelectorAll('.relay-board-inline').forEach((node) => node.remove());
91
+ return copy.textContent || '';
92
+ }
93
+
94
+ function buildBoardSection(board, { duplicate, standalone = false }) {
95
+ const section = document.createElement('div');
96
+ section.className = `relay-board-inline${standalone ? ' relay-board-card' : ''}`;
97
+ section.dataset.boardId = board.id;
98
+ section.dataset.boardKey = boardRenderKey(board, { duplicate });
99
+
100
+ const title = String(board.title || 'Plan ready for review').trim();
101
+ const parts = [];
102
+ if (!duplicate) {
103
+ parts.push(`<div class="relay-board-head">${escHtml(title)}</div>`);
104
+ parts.push(`<div class="relay-board-body">${renderMarkdownPreview(board.body || '', false)}</div>`);
105
+ }
106
+ const actions = Array.isArray(board.actions) ? board.actions : [];
107
+ const outcome = boardOutcomeLine(board);
108
+ if (outcome) {
109
+ parts.push(`<div class="relay-board-outcome">${escHtml(outcome)}</div>`);
110
+ } else if (actions.length && !IS_SHARED_VIEW) {
111
+ const recommendedAction = String(board.recommendedAction || '').trim().toLowerCase();
112
+ parts.push(`<div class="relay-board-actions">${
113
+ actions.map((action) => {
114
+ const actionId = String(action?.id || '').trim().toLowerCase();
115
+ if (!actionId) return '';
116
+ const actionLabel = String(action?.label || actionId).trim();
117
+ const isRecommended = recommendedAction && actionId === recommendedAction;
118
+ return `<button type="button" class="relay-board-action${isRecommended ? ' relay-board-action-recommended' : ''}" data-action-id="${escHtml(actionId)}" onclick="submitRelayBoardAction('${escHtml(board.id)}', this.dataset.actionId)">${escHtml(actionLabel)}</button>`;
119
+ }).join('')
120
+ }</div>`);
121
+ }
122
+ section.innerHTML = parts.join('');
123
+ section.querySelectorAll('pre code').forEach((node) => hljs.highlightElement(node));
124
+ attachCodeCopyButtons(section);
125
+ return section;
126
+ }
127
+
128
+ /** Below what the reply says, above its attachments, tool lines and subagents. */
129
+ function placeBoardSection(bubble, section, { live }) {
130
+ const before = live
131
+ ? bubble.querySelector(':scope > .dots, :scope > #thinking-activity, :scope > .subagent-bubbles-container')
132
+ : bubble.querySelector(':scope > .msg-attachments, :scope > .msg-activity, :scope > .msg-workflow-runs, :scope > .msg-preview-cards, :scope > .msg-bubble-actions');
133
+ if (before) bubble.insertBefore(section, before);
134
+ else bubble.appendChild(section);
135
+ }
136
+
137
+ /**
138
+ * Put every board of the conversation on screen where it belongs. Runs after
139
+ * every render of the transcript, which rebuilds the bubbles: a board that is
140
+ * in place and unchanged is left alone, so a selection inside it survives.
141
+ */
33
142
  export function renderRelayBoards() {
34
143
  const el = document.getElementById('messages');
35
144
  if (!el) return;
36
- const boards = Array.from(relayBoards.values())
37
- .filter((board) => board && board.conversationId === currentConvId && board.status === 'pending')
38
- .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
39
-
40
- const nextHash = JSON.stringify(
41
- boards.map((board) => ({
42
- id: board.id,
43
- status: board.status,
44
- title: board.title || '',
45
- body: board.body || '',
46
- createdAt: board.createdAt || '',
47
- actions: Array.isArray(board.actions) ? board.actions.map((action) => action?.id || '') : [],
48
- recommendedAction: board.recommendedAction || '',
49
- })),
50
- );
51
- const existingCards = el.querySelectorAll('.relay-board-container');
52
- if (relayBoardRenderHash === nextHash && existingCards.length === boards.length) return;
53
- relayBoardRenderHash = nextHash;
54
-
55
- existingCards.forEach((node) => node.remove());
56
- if (!boards.length) return;
145
+ if (currentConvId && boardHistoryLoadedFor !== currentConvId) void loadBoardHistory(currentConvId).catch(() => {});
146
+ const boards = boardsOfConversation(relayBoards.values(), currentConvId);
147
+ const placed = new Set();
148
+ let added = false;
57
149
 
58
150
  for (const board of boards) {
59
- const wrapper = document.createElement('div');
60
- wrapper.className = 'msg relay-board-container';
61
- wrapper.dataset.boardId = board.id;
62
-
63
- const modeTag = board.mode ? ` <span class="msg-mode">${escHtml(board.mode)}</span>` : '';
64
- const boardType = String(board.boardType || 'board').trim().replace(/[_-]+/g, ' ');
65
- const title = String(board.title || 'Plan ready for review').trim();
66
- const bodyHtml = renderMarkdownPreview(board.body || '', false);
67
- const actions = Array.isArray(board.actions) ? board.actions : [];
68
- const recommendedAction = String(board.recommendedAction || '').trim().toLowerCase();
69
- const actionHtml = actions.length
70
- ? `<div class="relay-board-actions">${
71
- actions.map((action) => {
72
- const actionId = String(action?.id || '').trim().toLowerCase();
73
- if (!actionId) return '';
74
- const actionLabel = String(action?.label || actionId).trim();
75
- const isRecommended = recommendedAction && actionId === recommendedAction;
76
- return `<button class="relay-board-action${isRecommended ? ' relay-board-action-recommended' : ''}" data-action-id="${escHtml(actionId)}" onclick="submitRelayBoardAction('${board.id}', this.dataset.actionId)">${escHtml(actionLabel)}</button>`;
77
- }).join('')
78
- }</div>`
79
- : '';
80
-
81
- wrapper.innerHTML = `
82
- <div class="relay-board-card">
83
- <div class="relay-board-head">${escHtml(title)}${modeTag} · ${escHtml(boardType)} · ${fmtDate(board.createdAt)}</div>
84
- <div class="relay-board-body">${bodyHtml}</div>
85
- ${actionHtml}
86
- </div>`;
87
- wrapper.querySelectorAll('pre code').forEach((node) => hljs.highlightElement(node));
88
- attachCodeCopyButtons(wrapper);
89
- el.appendChild(wrapper);
151
+ const host = findBoardHost(el, board);
152
+ const pending = board.status === 'pending';
153
+ let target = host?.bubble || null;
154
+ let standalone = false;
155
+ if (!target) {
156
+ // No bubble to live in (its turn is further up than the page has
157
+ // loaded): a pending board still has to be answerable.
158
+ if (!pending) continue;
159
+ let wrapper = el.querySelector(`.relay-board-container[data-board-id="${CSS.escape(board.id)}"]`);
160
+ if (!wrapper) {
161
+ wrapper = document.createElement('div');
162
+ wrapper.className = 'msg relay-board-container';
163
+ wrapper.dataset.boardId = board.id;
164
+ el.appendChild(wrapper);
165
+ }
166
+ target = wrapper;
167
+ standalone = true;
168
+ }
169
+ const duplicate = !standalone && boardBodyShownInReply(
170
+ renderedText(renderMarkdownPreview(board.body || '', false)),
171
+ bubbleTextWithoutBoards(target),
172
+ );
173
+ const key = boardRenderKey(board, { duplicate });
174
+ const existing = target.querySelector(`:scope > .relay-board-inline[data-board-id="${CSS.escape(board.id)}"]`);
175
+ placed.add(existing && existing.dataset.boardKey === key ? existing : null);
176
+ if (existing && existing.dataset.boardKey === key) continue;
177
+ const section = buildBoardSection(board, { duplicate, standalone });
178
+ if (existing) existing.replaceWith(section);
179
+ else if (standalone) target.appendChild(section);
180
+ else placeBoardSection(target, section, { live: host.live });
181
+ placed.add(section);
182
+ added = added || pending;
90
183
  }
91
- window.scrollBottom?.();
184
+
185
+ // What is on screen and no longer belongs there: a board that moved from
186
+ // the live bubble into the reply, or one of another conversation.
187
+ el.querySelectorAll('.relay-board-inline').forEach((node) => { if (!placed.has(node)) node.remove(); });
188
+ el.querySelectorAll('.relay-board-container').forEach((node) => { if (!node.querySelector('.relay-board-inline')) node.remove(); });
189
+ if (added) window.scrollBottom?.();
92
190
  }
93
191
 
94
192
  export async function submitRelayBoardAction(boardId, actionId) {
95
193
  const id = String(boardId || '').trim();
96
194
  const nextActionId = String(actionId || '').trim();
97
195
  if (!id || !nextActionId) return;
98
- const card = document.querySelector(`.relay-board-container[data-board-id="${id}"]`);
196
+ const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
99
197
  const controls = card ? card.querySelectorAll('button') : [];
100
198
  controls.forEach((control) => { control.disabled = true; });
101
199
 
102
200
  try {
103
201
  const r = await submitRelayBoardActionApi(id, nextActionId);
104
202
  if (!r?.board) throw new Error('Failed to submit board action');
203
+ // Kept once it is settled: the reply goes on showing the plan and says
204
+ // what was chosen.
105
205
  relayBoards.set(id, r.board);
106
- if (String(r.board.status || '').toLowerCase() !== 'pending') {
107
- relayBoards.delete(id);
108
- }
109
206
  window.renderConvList?.();
110
207
  renderRelayBoards();
111
- window.showTransientRelayNotice?.(`✅ Action selected: ${nextActionId}`);
112
208
  } catch (error) {
113
209
  controls.forEach((control) => { control.disabled = false; });
114
210
  alert(error.message || 'Failed to submit board action');
@@ -13,6 +13,10 @@
13
13
  // Codes are matched post-normalisation: normalizeTerminalErrorCode() lowercases
14
14
  // and rewrites every non-alphanumeric run to a dash, so `grok.cli_missing`
15
15
  // reaches the transcript as `relay.grok-cli-missing`.
16
+ const CLAUDE_SETTINGS_CTA = Object.freeze([
17
+ Object.freeze({ action: 'open-claude-settings', label: 'Claude settings' }),
18
+ ]);
19
+
16
20
  const RELAY_ERROR_CTAS = Object.freeze({
17
21
  'grok-cli-missing': Object.freeze([
18
22
  Object.freeze({ action: 'install-grok-cli', label: 'Install Grok CLI' }),
@@ -24,16 +28,35 @@ const RELAY_ERROR_CTAS = Object.freeze({
24
28
  ]),
25
29
  // Shipped with the Claude relogin plan (§4.3 there) as the deep link its
26
30
  // reworded message points at.
27
- 'claude-authentication-failed': Object.freeze([
28
- Object.freeze({ action: 'open-claude-settings', label: 'Claude settings' }),
29
- ]),
31
+ 'claude-authentication-failed': CLAUDE_SETTINGS_CTA,
32
+ // A turn the Claude account refused (claude-turn-failure.mjs): the panel
33
+ // shows the account and signs in to another one.
34
+ 'claude-billing-error': CLAUDE_SETTINGS_CTA,
35
+ 'claude-oauth-org-not-allowed': CLAUDE_SETTINGS_CTA,
36
+ 'claude-account-on-hold': CLAUDE_SETTINGS_CTA,
37
+ 'claude-verification-required': CLAUDE_SETTINGS_CTA,
38
+ 'claude-account-access': CLAUDE_SETTINGS_CTA,
30
39
  });
31
40
 
32
41
  const STABLE_CODE_PATTERN = /error code:\s*relay\.([a-z0-9-]+)/i;
42
+ // The shape of a failure note, mirrored from shared/failure-note-text.mjs
43
+ // (the browser cannot import it; failure-note-text.test.mjs keeps the two in
44
+ // step): one paragraph of at most this length leading up to the code, not a
45
+ // quotation, a code span, a table cell, a list item or a heading. A reply
46
+ // that talks about a code gets no button.
47
+ const FAILURE_NOTE_LEAD_MAX = 600;
48
+ const QUOTED_LEAD = /["“”'`|>]\s*$/;
49
+ const MARKUP_LEAD = /^\s*(?:#+|[>|*-]|\d+\.)\s/;
33
50
 
51
+ /** The code of a text that is a failure note of the relay, else ''. */
34
52
  export function relayErrorCodeFromText(text) {
35
- const match = String(text || '').match(STABLE_CODE_PATTERN);
36
- return match ? match[1].toLowerCase() : '';
53
+ const raw = String(text || '').trim();
54
+ const match = raw.match(STABLE_CODE_PATTERN);
55
+ if (!match) return '';
56
+ const lead = raw.slice(0, match.index);
57
+ if (lead.length > FAILURE_NOTE_LEAD_MAX || /[\r\n]/.test(lead)) return '';
58
+ if (QUOTED_LEAD.test(lead) || MARKUP_LEAD.test(lead)) return '';
59
+ return match[1].toLowerCase();
37
60
  }
38
61
 
39
62
  /**