@oar-sh/oar 0.9.5 → 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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +168 -0
- package/README.md +20 -4
- package/package.json +2 -1
- package/server/claude-worker/claude-session-process.mjs +118 -1
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +95 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +90 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +85 -14
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +36 -0
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +37 -17
- package/server/services/remote-relay-dispatcher.mjs +72 -8
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +16 -2
- package/shared/steer-settle-failure.mjs +16 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -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) {
|
|
@@ -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:
|
|
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
|
+
}
|
package/server/public/index.html
CHANGED
|
@@ -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
|
|
@@ -3008,6 +3068,17 @@
|
|
|
3008
3068
|
.file-preview-markdown th, .file-preview-markdown td { border: 1px solid var(--border); padding: 6px 9px; }
|
|
3009
3069
|
.file-preview-markdown th { background: var(--bg3); }
|
|
3010
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; }
|
|
3011
3082
|
.file-preview-markdown pre,
|
|
3012
3083
|
.file-preview-code pre {
|
|
3013
3084
|
background: #0b0f14;
|
|
@@ -4627,6 +4698,23 @@
|
|
|
4627
4698
|
and overflow horizontally. Pin to 0 so children wrap/scroll instead. */
|
|
4628
4699
|
min-width: 0;
|
|
4629
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); }
|
|
4630
4718
|
.relay-board-head {
|
|
4631
4719
|
display: flex;
|
|
4632
4720
|
align-items: center;
|
|
@@ -5324,6 +5412,7 @@
|
|
|
5324
5412
|
<div id="input-area">
|
|
5325
5413
|
<div id="offline-banner">⚠️ CLI is offline — messages will queue until it reconnects</div>
|
|
5326
5414
|
<div id="model-banner"></div>
|
|
5415
|
+
<div id="usage-limit-banner" role="status" aria-live="polite" hidden></div>
|
|
5327
5416
|
<div id="model-lock-note" hidden></div>
|
|
5328
5417
|
<div id="model-metadata-blocker" aria-live="polite">
|
|
5329
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
|
|