@oar-sh/oar 0.9.4 → 0.9.6

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 (71) 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 +199 -0
  4. package/README.md +35 -6
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  7. package/server/claude-worker/claude-session-process.mjs +411 -19
  8. package/server/claude-worker/claude-session-worker.mjs +5 -0
  9. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  10. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  11. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  12. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  13. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  14. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  17. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  19. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  20. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  21. package/server/db-schema.mjs +7 -0
  22. package/server/grok-worker/acp-client.mjs +68 -12
  23. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  24. package/server/grok-worker/grok-session-worker.mjs +5 -0
  25. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  26. package/server/public/app/api-client.js +17 -2
  27. package/server/public/app/bootstrap.js +3 -0
  28. package/server/public/app/composer-control-state.mjs +4 -3
  29. package/server/public/app/conversation-view.js +97 -4
  30. package/server/public/app/journal-view.js +6 -0
  31. package/server/public/app/markdown-strikethrough.mjs +31 -0
  32. package/server/public/app/relay-board-inline.mjs +70 -0
  33. package/server/public/app/relay-board-view.js +157 -61
  34. package/server/public/app/relay-error-ctas.mjs +12 -3
  35. package/server/public/app/socket-handlers.js +9 -0
  36. package/server/public/app/steered-turn-merge.mjs +10 -1
  37. package/server/public/app/store.js +4 -1
  38. package/server/public/app/subagent-fold.mjs +61 -0
  39. package/server/public/app/usage-limit-ui.js +225 -0
  40. package/server/public/index.html +97 -4
  41. package/server/repositories/message-repository.mjs +10 -1
  42. package/server/repositories/question-repository.mjs +1 -0
  43. package/server/repositories/remote-relay-repository.mjs +30 -0
  44. package/server/routes/messages-routes.mjs +93 -17
  45. package/server/routes/relay-board-routes.mjs +56 -2
  46. package/server/routes/sessions-routes.mjs +40 -2
  47. package/server/server-runtime.mjs +33 -2
  48. package/server/services/preview-proxy-server.mjs +64 -36
  49. package/server/services/process-list-service.mjs +122 -0
  50. package/server/services/relay-console-log.mjs +199 -0
  51. package/server/services/remote-relay-caller-context.mjs +45 -20
  52. package/server/services/remote-relay-dispatcher.mjs +328 -21
  53. package/server/services/remote-relay-inbound.mjs +35 -0
  54. package/server/services/session-worker-launch-service.mjs +17 -2
  55. package/server/services/session-worker-process-service.mjs +85 -32
  56. package/server/services/session-worker-registry-service.mjs +1 -1
  57. package/server/services/session-worker-supervisor-service.mjs +33 -1
  58. package/server/services/usage-limit-pause-service.mjs +441 -0
  59. package/shared/ask-user-bridge.mjs +14 -39
  60. package/shared/claude-usage-limit.mjs +257 -0
  61. package/shared/control-poller.mjs +69 -20
  62. package/shared/failure-note-text.mjs +38 -0
  63. package/shared/question-wait.mjs +108 -0
  64. package/shared/remote-relay-contract.mjs +33 -2
  65. package/shared/steer-settle-failure.mjs +22 -0
  66. package/shared/steer-settle-markers.mjs +24 -0
  67. package/shared/worker-runtime/process-tree.mjs +483 -0
  68. package/shared/worker-runtime/relay-errors.mjs +31 -0
  69. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  70. package/shared/worker-runtime/turn-liveness.mjs +249 -0
  71. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -49,6 +49,7 @@ import { applyGrokAuthState } from './grok-auth-ui.js';
49
49
  import { applyCliInstallState } from './cli-install-ui.js';
50
50
  import { renderUpdateSection } from './update-ui.js';
51
51
  import { applyHostSuspendState, applyRelayShutdownState } from './host-suspend-ui.js';
52
+ import { applyClaudeUsageLimit, applyUsageLimitPause } from './usage-limit-ui.js';
52
53
  import {
53
54
  showThinking,
54
55
  removeThinking,
@@ -399,6 +400,14 @@ export async function connectSocket(overrideDeps) {
399
400
  socket.on('relay_shutdown_state', (payload) => {
400
401
  applyRelayShutdownState(payload || null);
401
402
  });
403
+ // The Claude usage limit: a conversation's paused turn, and the account's
404
+ // latest report (also sent on connect).
405
+ socket.on('usage_limit_pause', (payload) => {
406
+ applyUsageLimitPause(payload || null);
407
+ });
408
+ socket.on('claude_usage_limit', (payload) => {
409
+ applyClaudeUsageLimit(payload || null);
410
+ });
402
411
  socket.on('grok_settings_updated', (payload) => {
403
412
  deps?.applyGrokSettingsState?.(payload || {});
404
413
  if (Number(payload?.reconciliation?.updatedUnstartedConversations || 0) > 0) {
@@ -10,6 +10,10 @@
10
10
  // - stopped: the steer was pushed into a turn the user then stopped, so it
11
11
  // went unanswered (kind='stopped', a marker with Resend).
12
12
  // The steered user message of a fold or stop is still classed as steered.
13
+ // A fourth marker is not a steer's: kind='answered-elsewhere' closes a message
14
+ // whose answer was published on a background-continuation row. It gets the
15
+ // markers' muted look and nothing else — its message was not steered, and the
16
+ // reply next to it is an ordinary one.
13
17
  //
14
18
  // One idempotent DOM pass, same contract as syncTranscriptSeparators: every
15
19
  // insertion path (full render, live append, prepended history page) re-runs it,
@@ -23,11 +27,16 @@ export const STEERED_MSG_CLASS = 'msg-steered';
23
27
  export const STEERED_CONTINUATION_CLASS = 'msg-steered-continuation';
24
28
  export const FOLDED_MSG_CLASS = 'msg-folded';
25
29
  export const STEER_STOPPED_MSG_CLASS = 'msg-steer-stopped';
30
+ export const ANSWERED_ELSEWHERE_MSG_CLASS = 'msg-answered-elsewhere';
26
31
 
27
- /** The assistant row kinds that are settle markers of a steer, not replies. */
32
+ /**
33
+ * The assistant row kinds that are settle markers, not replies. The first two
34
+ * are a steer's (see isSteerMarker); the last only shares their styling.
35
+ */
28
36
  export const STEER_MARKER_CLASS_BY_KIND = Object.freeze({
29
37
  folded: FOLDED_MSG_CLASS,
30
38
  stopped: STEER_STOPPED_MSG_CLASS,
39
+ 'answered-elsewhere': ANSWERED_ELSEWHERE_MSG_CLASS,
31
40
  });
32
41
 
33
42
  function isSteerMarker(node) {
@@ -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
+ }
@@ -0,0 +1,225 @@
1
+ // The Claude subscription's usage limit, as the open conversation sees it: a
2
+ // banner above the composer. Before the limit it warns ("usage at 96 %"); at
3
+ // the limit it says when the window resets; and when a turn of this
4
+ // conversation was paused there, it says when the turn carries on, with
5
+ // Resume now and Cancel.
6
+ //
7
+ // State arrives over the socket (`usage_limit_pause` per conversation,
8
+ // `claude_usage_limit` for the account), with the conversation payload, and
9
+ // with GET /api/status polls, so a client that missed an event catches up.
10
+
11
+ import { showTransientRelayNotice } from './store.js';
12
+ import { cancelUsageLimitPause, resumeUsageLimitPause } from './api-client.js';
13
+
14
+ const BANNER_ID = 'usage-limit-banner';
15
+ const DISMISSED_KEY = 'oar.usageLimitWarningDismissed';
16
+ // The CLI warns from 90 % on; a report below that is not worth a banner.
17
+ const WARNING_FROM = 0.9;
18
+
19
+ let account = null;
20
+ const pauses = new Map();
21
+ let currentConversationId = '';
22
+ let currentProviderType = '';
23
+ let actionInFlight = false;
24
+ let wakeTimer = null;
25
+
26
+ function isFuture(iso, nowMs) {
27
+ const at = Date.parse(String(iso || ''));
28
+ return Number.isFinite(at) && at > nowMs;
29
+ }
30
+
31
+ /** "22:01" today, "Thu 2 Oct, 09:00" on another day. */
32
+ export function formatUsageLimitTime(iso, nowMs = Date.now()) {
33
+ const at = new Date(String(iso || ''));
34
+ if (Number.isNaN(at.getTime())) return '';
35
+ const clock = at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
36
+ if (new Date(nowMs).toDateString() === at.toDateString()) return clock;
37
+ const day = at.toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' });
38
+ return `${day}, ${clock}`;
39
+ }
40
+
41
+ /** The pause of a conversation while it still holds the turn back. */
42
+ export function activeUsageLimitPause(conversationId, nowMs = Date.now()) {
43
+ const pause = pauses.get(String(conversationId || '')) || null;
44
+ if (!pause) return null;
45
+ // One that resumes by itself is over once its time has come.
46
+ if (pause.auto && !isFuture(pause.resumeAt, nowMs)) return null;
47
+ return pause;
48
+ }
49
+
50
+ export function usageLimitPauseText(pause, nowMs = Date.now()) {
51
+ if (!pause) return '';
52
+ const label = String(pause.label || 'usage limit');
53
+ if (pause.auto) {
54
+ return `⏸ Paused at the Claude ${label} — carries on at ${formatUsageLimitTime(pause.resumeAt, nowMs)}`;
55
+ }
56
+ if (isFuture(pause.resetsAt, nowMs)) {
57
+ return `⏸ Paused at the Claude ${label} — resets ${formatUsageLimitTime(pause.resetsAt, nowMs)}`;
58
+ }
59
+ if (!pause.resetsAt) return `⏸ Paused at the Claude ${label}`;
60
+ return `⏸ Paused at the Claude ${label} — the limit has reset`;
61
+ }
62
+
63
+ /** The account's line: a warning before the limit, the reset at the limit. */
64
+ export function usageLimitAccountText(state, nowMs = Date.now()) {
65
+ if (!state || !isFuture(state.resetsAt, nowMs) || state.isUsingOverage) return '';
66
+ const label = String(state.label || 'usage limit');
67
+ const resets = formatUsageLimitTime(state.resetsAt, nowMs);
68
+ if (state.status === 'rejected') {
69
+ return `Claude ${label} reached — resets ${resets}. A turn sent before that is paused until then.`;
70
+ }
71
+ const utilization = Number(state.utilization);
72
+ if (state.status !== 'allowed_warning' || !Number.isFinite(utilization) || utilization < WARNING_FROM) return '';
73
+ return `Claude usage at ${Math.round(utilization * 100)} % of the ${label} — resets ${resets}`;
74
+ }
75
+
76
+ function dismissedKey(state) {
77
+ return state ? `${state.rateLimitType || ''}|${state.resetsAt || ''}|${state.status || ''}` : '';
78
+ }
79
+
80
+ function isDismissed(state) {
81
+ try { return localStorage.getItem(DISMISSED_KEY) === dismissedKey(state); } catch { return false; }
82
+ }
83
+
84
+ function button(label, className, onClick) {
85
+ const btn = document.createElement('button');
86
+ btn.type = 'button';
87
+ btn.className = `relay-toast-action ${className}`;
88
+ btn.textContent = label;
89
+ btn.disabled = actionInFlight;
90
+ btn.addEventListener('click', (event) => {
91
+ event.preventDefault();
92
+ event.stopPropagation();
93
+ void onClick();
94
+ });
95
+ return btn;
96
+ }
97
+
98
+ async function runPauseAction(action) {
99
+ const conversationId = currentConversationId;
100
+ if (actionInFlight || !conversationId) return;
101
+ actionInFlight = true;
102
+ renderUsageLimitBanner();
103
+ try {
104
+ const result = action === 'resume'
105
+ ? await resumeUsageLimitPause(conversationId)
106
+ : await cancelUsageLimitPause(conversationId);
107
+ if (!result?.ok) {
108
+ showTransientRelayNotice(action === 'resume' ? 'Failed to resume the paused turn.' : 'Failed to cancel the paused turn.', 5000);
109
+ return;
110
+ }
111
+ pauses.delete(conversationId);
112
+ if (action === 'resume') {
113
+ showTransientRelayNotice(result.resumed ? 'Paused turn resumed.' : 'No turn was paused.', 4000);
114
+ } else {
115
+ showTransientRelayNotice(result.cancelled ? 'Paused turn cancelled.' : 'No turn was paused.', 4000);
116
+ }
117
+ } finally {
118
+ actionInFlight = false;
119
+ renderUsageLimitBanner();
120
+ }
121
+ }
122
+
123
+ function dismissWarning() {
124
+ try { localStorage.setItem(DISMISSED_KEY, dismissedKey(account)); } catch {}
125
+ renderUsageLimitBanner();
126
+ }
127
+
128
+ // The banner says nothing about time passing, but it ends with it: wake up
129
+ // when the turn carries on or the window resets.
130
+ function syncWakeTimer(nowMs) {
131
+ if (wakeTimer) { clearTimeout(wakeTimer); wakeTimer = null; }
132
+ const pause = pauses.get(currentConversationId) || null;
133
+ const times = [pause?.auto ? pause.resumeAt : pause?.resetsAt, account?.resetsAt]
134
+ .map((iso) => Date.parse(String(iso || '')))
135
+ .filter((at) => Number.isFinite(at) && at > nowMs);
136
+ if (!times.length) return;
137
+ const delay = Math.min(Math.min(...times) - nowMs + 500, 0x7fffffff);
138
+ wakeTimer = setTimeout(() => renderUsageLimitBanner(), delay);
139
+ wakeTimer?.unref?.();
140
+ }
141
+
142
+ export function renderUsageLimitBanner(nowMs = Date.now()) {
143
+ if (typeof document === 'undefined') return;
144
+ const banner = document.getElementById(BANNER_ID);
145
+ if (!banner) return;
146
+ const children = [];
147
+ const pause = activeUsageLimitPause(currentConversationId, nowMs);
148
+ if (pause) {
149
+ const text = document.createElement('span');
150
+ text.className = 'usage-limit-text';
151
+ text.textContent = usageLimitPauseText(pause, nowMs);
152
+ children.push(text, button('Resume now', 'usage-limit-resume', () => runPauseAction('resume')), button('Cancel', 'usage-limit-cancel', () => runPauseAction('cancel')));
153
+ banner.dataset.state = 'paused';
154
+ } else {
155
+ const line = currentConversationId && currentProviderType === 'claude' && !isDismissed(account)
156
+ ? usageLimitAccountText(account, nowMs)
157
+ : '';
158
+ if (line) {
159
+ const text = document.createElement('span');
160
+ text.className = 'usage-limit-text';
161
+ text.textContent = line;
162
+ children.push(text, button('Hide', 'usage-limit-dismiss', async () => dismissWarning()));
163
+ banner.dataset.state = account.status === 'rejected' ? 'reached' : 'warning';
164
+ }
165
+ }
166
+ banner.replaceChildren(...children);
167
+ banner.classList.toggle('visible', children.length > 0);
168
+ banner.hidden = children.length === 0;
169
+ if (!children.length) delete banner.dataset.state;
170
+ syncWakeTimer(nowMs);
171
+ }
172
+
173
+ /** `usage_limit_pause`: one conversation's pause, or its end (`pause: null`). */
174
+ export function applyUsageLimitPause(payload) {
175
+ const conversationId = String(payload?.conversationId || '').trim();
176
+ if (!conversationId) return;
177
+ if (payload.pause && typeof payload.pause === 'object') pauses.set(conversationId, payload.pause);
178
+ else pauses.delete(conversationId);
179
+ renderUsageLimitBanner();
180
+ }
181
+
182
+ /** `claude_usage_limit`: the account's latest report, or null past its reset. */
183
+ export function applyClaudeUsageLimit(state) {
184
+ account = state && typeof state === 'object' ? state : null;
185
+ renderUsageLimitBanner();
186
+ }
187
+
188
+ /** GET /api/status carries the account and every pause. */
189
+ export function applyUsageLimitFromStatus(status) {
190
+ if (!status || typeof status !== 'object' || !('usageLimit' in status)) return;
191
+ const usageLimit = status.usageLimit || {};
192
+ account = usageLimit.account && typeof usageLimit.account === 'object' ? usageLimit.account : null;
193
+ pauses.clear();
194
+ for (const pause of (Array.isArray(usageLimit.pauses) ? usageLimit.pauses : [])) {
195
+ const conversationId = String(pause?.conversationId || '').trim();
196
+ if (conversationId) pauses.set(conversationId, pause);
197
+ }
198
+ renderUsageLimitBanner();
199
+ }
200
+
201
+ /** The conversation on screen, with what its payload said about a pause. */
202
+ export function setUsageLimitConversation(conversationId, { pause = undefined, providerType = '' } = {}) {
203
+ currentConversationId = String(conversationId || '').trim();
204
+ currentProviderType = String(providerType || '').trim().toLowerCase();
205
+ if (currentConversationId && pause !== undefined) {
206
+ if (pause && typeof pause === 'object') pauses.set(currentConversationId, pause);
207
+ else pauses.delete(currentConversationId);
208
+ }
209
+ renderUsageLimitBanner();
210
+ }
211
+
212
+ export function initUsageLimitUi() {
213
+ renderUsageLimitBanner();
214
+ }
215
+
216
+ export function __resetUsageLimitUiForTests() {
217
+ account = null;
218
+ pauses.clear();
219
+ currentConversationId = '';
220
+ currentProviderType = '';
221
+ actionInFlight = false;
222
+ if (wakeTimer) { clearTimeout(wakeTimer); wakeTimer = null; }
223
+ try { localStorage.removeItem(DISMISSED_KEY); } catch {}
224
+ renderUsageLimitBanner();
225
+ }
@@ -173,7 +173,7 @@
173
173
  </script>
174
174
  <title>OAR — Open Agent Relay</title>
175
175
  <script>document.write('<scr'+'ipt src="'+__APP_BASE+'/socket.io/socket.io.js"><\/scr'+'ipt>');</script>
176
- <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
176
+ <script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
177
177
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css">
178
178
  <script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.js"></script>
179
179
  <script src="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/contrib/mhchem.min.js"></script>
@@ -1451,6 +1451,7 @@
1451
1451
  .thinking-bubble a,
1452
1452
  .subagent-bubble a,
1453
1453
  .relay-question-card a,
1454
+ .relay-board-body a,
1454
1455
  .workspace-file-link {
1455
1456
  color: var(--bubble-link);
1456
1457
  text-decoration-thickness: 1.2px;
@@ -1460,6 +1461,7 @@
1460
1461
  .thinking-bubble a:visited,
1461
1462
  .subagent-bubble a:visited,
1462
1463
  .relay-question-card a:visited,
1464
+ .relay-board-body a:visited,
1463
1465
  .workspace-file-link:visited { color: var(--bubble-link); }
1464
1466
  .msg.user .msg-bubble code:not(pre code) {
1465
1467
  background: var(--user-bubble-chip-bg);
@@ -1701,6 +1703,44 @@
1701
1703
  margin-bottom: 6px;
1702
1704
  font-size: var(--fs-meta-sm);
1703
1705
  }
1706
+ .subagent-bubble-header {
1707
+ cursor: pointer;
1708
+ -webkit-tap-highlight-color: transparent;
1709
+ }
1710
+ .subagent-bubble-header:focus-visible {
1711
+ outline: 2px solid var(--accent-color);
1712
+ outline-offset: 2px;
1713
+ border-radius: 4px;
1714
+ }
1715
+ .subagent-bubble-fold {
1716
+ flex-shrink: 0;
1717
+ width: 1em;
1718
+ color: var(--muted);
1719
+ text-align: center;
1720
+ }
1721
+ .subagent-bubble-fold::before { content: '▾'; }
1722
+ .subagent-bubble.folded > .subagent-bubble-header > .subagent-bubble-fold::before { content: '▸'; }
1723
+ /* Folded: the header alone. What the run wrote, thought and did, and the
1724
+ runs it started, come back with a tap. */
1725
+ .subagent-bubble.folded > :not(.subagent-bubble-header) { display: none; }
1726
+ .subagent-bubble.folded > .subagent-bubble-header { margin-bottom: 0; }
1727
+ .subagent-stop-btn[hidden] { display: none; }
1728
+ /* The folded header's one line about the run. It takes the room the name
1729
+ and the controls leave and is cut with an ellipsis; on a narrow bubble
1730
+ that is little or nothing, and the name keeps its place. */
1731
+ .subagent-bubble-summary {
1732
+ display: none;
1733
+ flex: 1 1 0;
1734
+ min-width: 0;
1735
+ overflow: hidden;
1736
+ text-overflow: ellipsis;
1737
+ white-space: nowrap;
1738
+ color: var(--muted);
1739
+ font-weight: 400;
1740
+ }
1741
+ .subagent-bubble.folded > .subagent-bubble-header > .subagent-bubble-summary { display: block; }
1742
+ .subagent-bubble.folded > .subagent-bubble-header > .subagent-bubble-name { flex: 0 1 auto; max-width: 55%; }
1743
+ .subagent-bubble.folded > .subagent-bubble-header > .subagent-bubble-summary:empty { flex-grow: 1; }
1704
1744
  .subagent-bubble-name {
1705
1745
  font-weight: 600;
1706
1746
  color: var(--text);
@@ -2339,6 +2379,26 @@
2339
2379
  text-align: center;
2340
2380
  }
2341
2381
  #model-banner.visible { display: block; }
2382
+ /* The Claude usage limit (usage-limit-ui.js): a warning before the limit,
2383
+ and a turn paused at it with Resume now / Cancel. In the flow of the
2384
+ composer like #model-banner, for the same reason. */
2385
+ #usage-limit-banner {
2386
+ display: none;
2387
+ align-items: center;
2388
+ flex-wrap: wrap;
2389
+ gap: 6px 10px;
2390
+ background: var(--bg2);
2391
+ background: color-mix(in srgb, var(--warn) 14%, var(--bg2));
2392
+ border: 1px solid var(--warn);
2393
+ color: var(--warn);
2394
+ border-radius: 8px;
2395
+ padding: 6px 10px;
2396
+ margin-bottom: 10px;
2397
+ font-size: var(--fs-meta-sm);
2398
+ }
2399
+ #usage-limit-banner.visible { display: flex; }
2400
+ #usage-limit-banner .usage-limit-text { flex: 1 1 12em; min-width: 0; }
2401
+ #usage-limit-banner .relay-toast-action { flex: 0 0 auto; }
2342
2402
  /* Transient relay notices (saves, errors, action confirmations) float as a
2343
2403
  fixed toast above every overlay — the annotation editor's error paths
2344
2404
  rely on being readable over its full-screen modal. pointer-events: none
@@ -2812,10 +2872,13 @@
2812
2872
  kind='absorbed' and the steered message carries the continuing reply. A
2813
2873
  steer cut off by Stop settles as kind='stopped' with a Resend button.
2814
2874
  Markers are notes, not answers, so they render muted and compact.
2815
- steered-turn-merge.mjs adds the adjacency classes. */
2875
+ steered-turn-merge.mjs adds the adjacency classes.
2876
+ kind='answered-elsewhere' is no steer's marker but looks like one: it
2877
+ closes a message whose answer is on a background-continuation row. */
2816
2878
  .msg.assistant.msg-absorbed .msg-bubble,
2817
2879
  .msg.assistant.msg-folded .msg-bubble,
2818
- .msg.assistant.msg-steer-stopped .msg-bubble {
2880
+ .msg.assistant.msg-steer-stopped .msg-bubble,
2881
+ .msg.assistant.msg-answered-elsewhere .msg-bubble {
2819
2882
  font-size: var(--fs-meta-md); color: var(--muted); font-style: italic;
2820
2883
  padding: 6px 12px; opacity: 0.85;
2821
2884
  }
@@ -2824,7 +2887,8 @@
2824
2887
  .msg.assistant.msg-folded,
2825
2888
  .msg.assistant.msg-steer-stopped { margin-top: -8px; }
2826
2889
  .msg.assistant.msg-folded .msg-label,
2827
- .msg.assistant.msg-steer-stopped .msg-label { display: none; }
2890
+ .msg.assistant.msg-steer-stopped .msg-label,
2891
+ .msg.assistant.msg-answered-elsewhere .msg-label { display: none; }
2828
2892
  .msg.assistant.msg-steer-stopped .msg-bubble-actions { font-style: normal; }
2829
2893
  .msg.user.msg-steered .msg-bubble { border-style: dashed; }
2830
2894
  .msg.user.msg-steered .msg-label::after { content: ' · steered mid-turn'; }
@@ -3004,6 +3068,17 @@
3004
3068
  .file-preview-markdown th, .file-preview-markdown td { border: 1px solid var(--border); padding: 6px 9px; }
3005
3069
  .file-preview-markdown th { background: var(--bg3); }
3006
3070
  .file-preview-markdown img { display: block; max-width: 100%; height: auto; }
3071
+ /* Without a colour of their own, links fell back to the browser's default
3072
+ blue, which cannot be read on the dark background. The accent is the
3073
+ calmer of the two blues: the bubbles' link colour is for a tinted bubble,
3074
+ and on the viewer's plain background it glares. */
3075
+ .file-preview-markdown a,
3076
+ .file-preview-markdown a:visited {
3077
+ color: var(--accent);
3078
+ text-decoration-thickness: 1.2px;
3079
+ text-underline-offset: 2px;
3080
+ }
3081
+ .file-preview-markdown a code { color: inherit; }
3007
3082
  .file-preview-markdown pre,
3008
3083
  .file-preview-code pre {
3009
3084
  background: #0b0f14;
@@ -4623,6 +4698,23 @@
4623
4698
  and overflow horizontally. Pin to 0 so children wrap/scroll instead. */
4624
4699
  min-width: 0;
4625
4700
  }
4701
+ /* A board inside the bubble of the reply it belongs to: set off from the
4702
+ reply's text by a rule, not by a card of its own. */
4703
+ .relay-board-inline {
4704
+ display: grid;
4705
+ gap: 10px;
4706
+ min-width: 0;
4707
+ margin-top: 12px;
4708
+ padding-top: 12px;
4709
+ border-top: 1px solid rgba(80, 227, 194, 0.35);
4710
+ }
4711
+ .relay-board-inline.relay-board-card { margin-top: 0; border-top: 0; }
4712
+ .relay-board-inline .relay-board-head { color: var(--text); font-weight: 600; font-size: var(--fs-meta-md); }
4713
+ .relay-board-outcome {
4714
+ color: var(--muted);
4715
+ font-size: var(--fs-meta-md);
4716
+ }
4717
+ .relay-board-outcome::before { content: '✓ '; color: var(--success); }
4626
4718
  .relay-board-head {
4627
4719
  display: flex;
4628
4720
  align-items: center;
@@ -5320,6 +5412,7 @@
5320
5412
  <div id="input-area">
5321
5413
  <div id="offline-banner">⚠️ CLI is offline — messages will queue until it reconnects</div>
5322
5414
  <div id="model-banner"></div>
5415
+ <div id="usage-limit-banner" role="status" aria-live="polite" hidden></div>
5323
5416
  <div id="model-lock-note" hidden></div>
5324
5417
  <div id="model-metadata-blocker" aria-live="polite">
5325
5418
  <span id="model-metadata-blocker-text">Model metadata is unavailable. Refresh to choose a model and reasoning effort.</span>
@@ -7,6 +7,14 @@ export function createMessageRepository(db) {
7
7
  const shareVisibility = createShareVisibilityStatements(db);
8
8
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
9
  .some((column) => column.name === 'image_operation_id');
10
+ // A turn paused at the Claude usage limit (usage-limit-pause-service): a
11
+ // pending follow-up held until the reset. It is not work waiting for a
12
+ // worker, so it neither keeps one warm nor counts as queued.
13
+ const queueHasUsageLimitPause = db.prepare(`PRAGMA table_info(queue)`).all()
14
+ .some((column) => column.name === 'usage_limit_pause');
15
+ const notHeldForUsageLimit = (alias = '') => (queueHasUsageLimitPause
16
+ ? `AND NOT (${alias}usage_limit_pause IS NOT NULL AND ${alias}status = 'pending' AND ${alias}next_attempt_at IS NOT NULL AND ${alias}next_attempt_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`
17
+ : '');
10
18
  const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
11
19
  const messagesHaveSourceId = messageColumns.has('source_message_id');
12
20
  const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
@@ -270,6 +278,7 @@ export function createMessageRepository(db) {
270
278
  LEFT JOIN conversations c
271
279
  ON c.id = q.conversation_id
272
280
  WHERE q.status = 'pending'
281
+ ${notHeldForUsageLimit('q.')}
273
282
  AND COALESCE(
274
283
  NULLIF(q.owner_sdk_session_id, ''),
275
284
  NULLIF(rs.sdk_session_id, ''),
@@ -283,7 +292,7 @@ export function createMessageRepository(db) {
283
292
  ORDER BY MIN(q.timestamp) ASC
284
293
  LIMIT ?
285
294
  `),
286
- countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
295
+ countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') ${notHeldForUsageLimit()} GROUP BY status`),
287
296
  // Deferred host suspend: which conversations still hold live turns.
288
297
  listActiveQueueCountsByConversation: db.prepare(`
289
298
  SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
@@ -174,6 +174,7 @@ export function createQuestionRepository(db) {
174
174
  insertBoard: db.prepare(`INSERT INTO relay_boards (id, queue_id, conversation_id, message_id, board_type, relay_mode, title, body, actions_json, recommended_action, context_json, status, selected_action, acted_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?)`),
175
175
  getBoard: db.prepare(`SELECT * FROM relay_boards WHERE id = ?`),
176
176
  findBoardByMessageType: db.prepare(`SELECT * FROM relay_boards WHERE message_id = ? AND board_type = ? ORDER BY created_at DESC LIMIT 1`),
177
+ listBoardsForConversation: db.prepare(`SELECT * FROM relay_boards WHERE conversation_id = ? ORDER BY created_at ASC`),
177
178
  listBoards: db.prepare(`SELECT * FROM relay_boards WHERE status = ? AND (? IS NULL OR conversation_id = ?) ORDER BY created_at ASC`),
178
179
  markBoardAction: db.prepare(`UPDATE relay_boards SET status = 'acted', selected_action = ?, acted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'`),
179
180
  dismissBoard: db.prepare(`UPDATE relay_boards SET status = 'dismissed', selected_action = ?, acted_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'`),
@@ -6,6 +6,10 @@ import { normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.m
6
6
  // conversation has unlocked (the user mentioned them), and the provenance of
7
7
  // messages and conversations another relay's agent created. Tables and
8
8
  // columns come from migrations/0005-remote-relays.mjs.
9
+ //
10
+ // A relay whose agent wrote to a conversation is open to that conversation as
11
+ // well. That is read from the provenance, not stored as an unlock, so what the
12
+ // user allowed and what another agent started stay apart.
9
13
 
10
14
  function parseOrigin(json) {
11
15
  if (!json) return null;
@@ -26,6 +30,15 @@ export function createRemoteRelayRepository(db) {
26
30
  const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
27
31
  const setConversationOrigin = db.prepare(`UPDATE conversations SET origin_json = ? WHERE id = ?`);
28
32
  const getConversationOrigin = db.prepare(`SELECT origin_json FROM conversations WHERE id = ?`);
33
+ const listUserMessageOrigins = db.prepare(`
34
+ SELECT DISTINCT origin_json FROM messages
35
+ WHERE conversation_id = ? AND role = 'user' AND origin_json IS NOT NULL
36
+ `);
37
+ const getLatestHumanMessage = db.prepare(`
38
+ SELECT id FROM messages
39
+ WHERE conversation_id = ? AND role = 'user' AND origin_json IS NULL
40
+ ORDER BY timestamp DESC LIMIT 1
41
+ `);
29
42
 
30
43
  return {
31
44
  hasUnlock(conversationId, remoteRelayId) {
@@ -66,6 +79,23 @@ export function createRemoteRelayRepository(db) {
66
79
  getConversationOrigin(conversationId) {
67
80
  return parseOrigin(getConversationOrigin.get(String(conversationId || ''))?.origin_json);
68
81
  },
82
+ /**
83
+ * Did an agent on the relay with this instance id (the `relayId` of an
84
+ * origin, not the local registry id) write to the conversation: by
85
+ * starting it, or with a prompt in it?
86
+ */
87
+ hasContactFrom(conversationId, remoteInstanceId) {
88
+ const conversation = String(conversationId || '').trim();
89
+ const instance = String(remoteInstanceId || '').trim();
90
+ if (!conversation || !instance) return false;
91
+ if (parseOrigin(getConversationOrigin.get(conversation)?.origin_json)?.relayId === instance) return true;
92
+ return listUserMessageOrigins.all(conversation)
93
+ .some((row) => parseOrigin(row.origin_json)?.relayId === instance);
94
+ },
95
+ /** The id of the latest message the user wrote themselves, or null. */
96
+ latestHumanMessageId(conversationId) {
97
+ return getLatestHumanMessage.get(String(conversationId || ''))?.id || null;
98
+ },
69
99
  };
70
100
  }
71
101