@oar-sh/oar 0.9.6 → 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.
- package/CHANGELOG.md +172 -0
- package/README.md +176 -22
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +33 -0
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +143 -4
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +344 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +83 -4
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +51 -0
- package/server/public/index.html +381 -7
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +120 -15
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +502 -72
- package/server/server-runtime.mjs +258 -25
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +1 -0
|
@@ -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
|
-
|
|
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
|
|
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 ?
|
|
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 ?
|
|
123
|
+
return remainderHours ? `${verb} in ${days} d ${remainderHours} h` : `${verb} in ${days} d`;
|
|
117
124
|
}
|
|
118
|
-
return
|
|
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
|
|
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);
|
|
@@ -39,10 +39,24 @@ const RELAY_ERROR_CTAS = Object.freeze({
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
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/;
|
|
42
50
|
|
|
51
|
+
/** The code of a text that is a failure note of the relay, else ''. */
|
|
43
52
|
export function relayErrorCodeFromText(text) {
|
|
44
|
-
const
|
|
45
|
-
|
|
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();
|
|
46
60
|
}
|
|
47
61
|
|
|
48
62
|
/**
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// to the source session) and the "↗ win-test" marker on the sidebar row of a
|
|
4
4
|
// conversation such an agent created. Both read the `origin` the relay stores
|
|
5
5
|
// with the message or conversation (docs/plans/2026-09-27-remote-relays.md §4).
|
|
6
|
+
//
|
|
7
|
+
// An agent on THIS relay can start sessions too. Such an origin carries
|
|
8
|
+
// `local: true` and names the conversation the agent runs in: the marker then
|
|
9
|
+
// reads "via agent · “title”" and opens that conversation inside the app
|
|
10
|
+
// (window.openOriginConversation, journal-view.js) instead of linking to
|
|
11
|
+
// another relay. An origin without `local` renders exactly as before.
|
|
6
12
|
|
|
7
13
|
import { remoteConversationUrl } from './remote-relay-shared.mjs';
|
|
8
14
|
|
|
@@ -19,6 +25,76 @@ function oneLine(value) {
|
|
|
19
25
|
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
// How much of the other conversation's title a marker carries; CSS ellipsis
|
|
29
|
+
// takes over where the row is narrower than that.
|
|
30
|
+
export const ORIGIN_TITLE_MAX_SIDEBAR = 28;
|
|
31
|
+
export const ORIGIN_TITLE_MAX = 60;
|
|
32
|
+
|
|
33
|
+
/** "a long conversation tit…": at most `max` characters, one line. */
|
|
34
|
+
export function truncateOriginTitle(value, max = ORIGIN_TITLE_MAX) {
|
|
35
|
+
const text = oneLine(value);
|
|
36
|
+
const limit = Math.max(2, Math.floor(Number(max)) || ORIGIN_TITLE_MAX);
|
|
37
|
+
// By code point, so an emoji is never cut in half.
|
|
38
|
+
const chars = Array.from(text);
|
|
39
|
+
if (chars.length <= limit) return text;
|
|
40
|
+
return `${chars.slice(0, limit - 1).join('').trimEnd()}…`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True for the origin of work an agent on this relay started. */
|
|
44
|
+
export function isLocalAgentOrigin(raw) {
|
|
45
|
+
return !!raw && typeof raw === 'object' && !Array.isArray(raw) && raw.local === true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function knownConversation(conversations, id) {
|
|
49
|
+
if (!id || !conversations || typeof conversations !== 'object') return null;
|
|
50
|
+
const found = Object.prototype.hasOwnProperty.call(conversations, id) ? conversations[id] : null;
|
|
51
|
+
return found && typeof found === 'object' ? found : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a local origin shows, or null when the origin is not a local one.
|
|
56
|
+
* `conversations` is the browser's list by id: the marker is a link only while
|
|
57
|
+
* the orchestrating conversation is in it, and shows that conversation's
|
|
58
|
+
* current title (the origin stores the one it had at creation). `ownId` is the
|
|
59
|
+
* conversation the marker sits on, which never links to itself.
|
|
60
|
+
*/
|
|
61
|
+
export function localAgentOriginModel(origin, {
|
|
62
|
+
conversations = null,
|
|
63
|
+
ownId = '',
|
|
64
|
+
linkable = true,
|
|
65
|
+
maxTitleLength = ORIGIN_TITLE_MAX,
|
|
66
|
+
} = {}) {
|
|
67
|
+
if (!isLocalAgentOrigin(origin)) return null;
|
|
68
|
+
const conversationId = oneLine(origin.conversationId);
|
|
69
|
+
const known = conversationId !== oneLine(ownId) ? knownConversation(conversations, conversationId) : null;
|
|
70
|
+
const fullTitle = oneLine(known?.title) || oneLine(origin.conversationTitle);
|
|
71
|
+
const title = truncateOriginTitle(fullTitle, maxTitleLength);
|
|
72
|
+
return {
|
|
73
|
+
conversationId,
|
|
74
|
+
fullTitle,
|
|
75
|
+
title,
|
|
76
|
+
model: oneLine(origin.model),
|
|
77
|
+
text: title ? `via agent · “${title}”` : 'via agent',
|
|
78
|
+
linkable: linkable !== false && !!known,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A button while the orchestrating conversation can be opened, plain text
|
|
83
|
+
// otherwise. `tooltip` is a full sentence.
|
|
84
|
+
function localAgentElementHtml(model, { className, text = model.text, tooltip }) {
|
|
85
|
+
const label = escapeHtml(text);
|
|
86
|
+
if (model.linkable) {
|
|
87
|
+
return `<button type="button" class="${className}" data-origin-conversation-id="${escapeHtml(model.conversationId)}" title="${escapeHtml(`${tooltip} Opens that conversation.`)}" onclick="openOriginConversation(event, this)">${label}</button>`;
|
|
88
|
+
}
|
|
89
|
+
return `<span class="${className}" title="${escapeHtml(tooltip)}">${label}</span>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function startedByTooltip(model) {
|
|
93
|
+
return model.fullTitle
|
|
94
|
+
? `Started by an agent in “${model.fullTitle}”.`
|
|
95
|
+
: 'Started by an agent in another conversation on this relay.';
|
|
96
|
+
}
|
|
97
|
+
|
|
22
98
|
/** The display fields of an `origin`, or null when it names no relay. */
|
|
23
99
|
export function normalizeRemoteOrigin(raw) {
|
|
24
100
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
@@ -35,7 +111,9 @@ export function normalizeRemoteOrigin(raw) {
|
|
|
35
111
|
};
|
|
36
112
|
}
|
|
37
113
|
|
|
38
|
-
export function remoteOriginBadgeText(origin) {
|
|
114
|
+
export function remoteOriginBadgeText(origin, options = {}) {
|
|
115
|
+
const local = localAgentOriginModel(origin, options);
|
|
116
|
+
if (local) return local.model ? `${local.text} · ${local.model}` : local.text;
|
|
39
117
|
const normalized = normalizeRemoteOrigin(origin);
|
|
40
118
|
if (!normalized) return '';
|
|
41
119
|
const parts = [`↗ from ${normalized.relayName}`];
|
|
@@ -54,8 +132,21 @@ export function remoteOriginLink(origin) {
|
|
|
54
132
|
/**
|
|
55
133
|
* The badge row that sits above a user bubble's text. `linkable: false` (the
|
|
56
134
|
* shared read-only view) renders it without the link to the other relay.
|
|
135
|
+
* `conversations` (the browser's list by id) lets a local agent's badge open
|
|
136
|
+
* the conversation that agent runs in.
|
|
57
137
|
*/
|
|
58
|
-
export function renderRemoteOriginBadgeHtml(origin, { linkable = true } = {}) {
|
|
138
|
+
export function renderRemoteOriginBadgeHtml(origin, { linkable = true, conversations = null } = {}) {
|
|
139
|
+
const local = localAgentOriginModel(origin, { conversations, linkable });
|
|
140
|
+
if (local) {
|
|
141
|
+
const badge = localAgentElementHtml(local, {
|
|
142
|
+
className: 'msg-origin-badge msg-origin-agent',
|
|
143
|
+
text: remoteOriginBadgeText(origin, { conversations }),
|
|
144
|
+
tooltip: local.fullTitle
|
|
145
|
+
? `Sent by an agent in “${local.fullTitle}”.`
|
|
146
|
+
: 'Sent by an agent in another conversation on this relay.',
|
|
147
|
+
});
|
|
148
|
+
return `<div class="msg-origin">${badge}</div>`;
|
|
149
|
+
}
|
|
59
150
|
const normalized = normalizeRemoteOrigin(origin);
|
|
60
151
|
if (!normalized) return '';
|
|
61
152
|
const label = escapeHtml(remoteOriginBadgeText(normalized));
|
|
@@ -67,10 +158,35 @@ export function renderRemoteOriginBadgeHtml(origin, { linkable = true } = {}) {
|
|
|
67
158
|
return `<div class="msg-origin"><span class="msg-origin-badge" title="Sent by an agent on relay ${name}.">${label}</span></div>`;
|
|
68
159
|
}
|
|
69
160
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
161
|
+
/**
|
|
162
|
+
* The sidebar marker for a conversation an agent created: "↗ win-test" for
|
|
163
|
+
* another relay's agent, "via agent · “title”" for one on this relay.
|
|
164
|
+
*/
|
|
165
|
+
export function renderConversationOriginMarkerHtml(conversation, { conversations = null, linkable = true } = {}) {
|
|
166
|
+
const local = localAgentOriginModel(conversation?.origin, {
|
|
167
|
+
conversations,
|
|
168
|
+
linkable,
|
|
169
|
+
ownId: conversation?.id,
|
|
170
|
+
maxTitleLength: ORIGIN_TITLE_MAX_SIDEBAR,
|
|
171
|
+
});
|
|
172
|
+
if (local) {
|
|
173
|
+
return localAgentElementHtml(local, {
|
|
174
|
+
className: 'conv-origin-marker conv-origin-agent',
|
|
175
|
+
tooltip: startedByTooltip(local),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
72
178
|
const normalized = normalizeRemoteOrigin(conversation?.origin);
|
|
73
179
|
if (!normalized) return '';
|
|
74
180
|
const name = escapeHtml(normalized.relayName);
|
|
75
181
|
return `<span class="conv-origin-marker" title="Started by an agent on relay ${name}">↗ ${name}</span>`;
|
|
76
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The same marker for the conversation header, with room for more of the
|
|
186
|
+
* title. Only a local origin has one; '' for everything else.
|
|
187
|
+
*/
|
|
188
|
+
export function renderConversationHeaderOriginHtml(conversation, { conversations = null, linkable = true } = {}) {
|
|
189
|
+
const local = localAgentOriginModel(conversation?.origin, { conversations, linkable, ownId: conversation?.id });
|
|
190
|
+
if (!local) return '';
|
|
191
|
+
return localAgentElementHtml(local, { className: 'conv-origin-agent', tooltip: startedByTooltip(local) });
|
|
192
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
WORKSPACE_FILE_EXTENSIONS,
|
|
7
7
|
serverPlatform,
|
|
8
8
|
} from './store.js';
|
|
9
|
+
import { scanInlineDollars, splitAtLiteralDollars } from './math-dollars.mjs';
|
|
9
10
|
|
|
10
11
|
export function workspaceMentionRegex() {
|
|
11
12
|
return /(?:[A-Za-z]:[\\/])?(?:\.{1,2}[\\/])?(?:[A-Za-z0-9._-]+[\\/])*[A-Za-z0-9._-]+\.[A-Za-z0-9]{1,16}/g;
|
|
@@ -397,7 +398,8 @@ function normalizeInlineMathDelimiters(line) {
|
|
|
397
398
|
let output = '';
|
|
398
399
|
let index = 0;
|
|
399
400
|
let inCode = false;
|
|
400
|
-
|
|
401
|
+
// Only real dollar maths is skipped over; a price's `$` opens nothing.
|
|
402
|
+
const dollarMathEnds = new Map(scanInlineDollars(line).spans);
|
|
401
403
|
|
|
402
404
|
while (index < line.length) {
|
|
403
405
|
const char = line[index];
|
|
@@ -412,15 +414,10 @@ function normalizeInlineMathDelimiters(line) {
|
|
|
412
414
|
index += 1;
|
|
413
415
|
continue;
|
|
414
416
|
}
|
|
415
|
-
if (char === '$' &&
|
|
416
|
-
|
|
417
|
-
output +=
|
|
418
|
-
index
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
if (inDollarMath) {
|
|
422
|
-
output += char;
|
|
423
|
-
index += 1;
|
|
417
|
+
if (char === '$' && dollarMathEnds.has(index)) {
|
|
418
|
+
const closeIndex = dollarMathEnds.get(index);
|
|
419
|
+
output += line.slice(index, closeIndex + 1);
|
|
420
|
+
index = closeIndex + 1;
|
|
424
421
|
continue;
|
|
425
422
|
}
|
|
426
423
|
if (char === '\\' && line[index + 1] === '(') {
|
|
@@ -469,6 +466,7 @@ function protectMarkdownEscapesInMath(source) {
|
|
|
469
466
|
let output = '';
|
|
470
467
|
let index = 0;
|
|
471
468
|
let inCode = false;
|
|
469
|
+
const dollarMathEnds = new Map(scanInlineDollars(line).spans);
|
|
472
470
|
while (index < line.length) {
|
|
473
471
|
if (line[index] === '`') {
|
|
474
472
|
inCode = !inCode;
|
|
@@ -482,7 +480,10 @@ function protectMarkdownEscapesInMath(source) {
|
|
|
482
480
|
continue;
|
|
483
481
|
}
|
|
484
482
|
const delimiter = line[index + 1] === '$' ? '$$' : '$';
|
|
485
|
-
|
|
483
|
+
// A single `$` that is a plain dollar sign (a price) opens no formula.
|
|
484
|
+
const closeIndex = delimiter === '$'
|
|
485
|
+
? (dollarMathEnds.has(index) ? dollarMathEnds.get(index) : -1)
|
|
486
|
+
: line.indexOf(delimiter, index + delimiter.length);
|
|
486
487
|
if (closeIndex < 0) {
|
|
487
488
|
output += line[index];
|
|
488
489
|
index += 1;
|
|
@@ -550,6 +551,40 @@ export function normalizeMathDelimiters(source) {
|
|
|
550
551
|
return protectMarkdownEscapesInMath(normalized.join('\n'));
|
|
551
552
|
}
|
|
552
553
|
|
|
554
|
+
const MATH_IGNORED_TAGS = ['pre', 'code', 'script', 'style', 'textarea', 'option', 'a'];
|
|
555
|
+
const LITERAL_DOLLAR_CLASS = 'literal-dollar';
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* KaTeX's auto-render pairs any two `$` in a block, so "$5 … $9" would become
|
|
559
|
+
* one formula. Each plain dollar sign (see math-dollars.mjs) is moved into a
|
|
560
|
+
* span of its own that auto-render skips; with no partner in its text node,
|
|
561
|
+
* it stays a dollar sign.
|
|
562
|
+
*/
|
|
563
|
+
function isolateLiteralDollars(container) {
|
|
564
|
+
const ignored = MATH_IGNORED_TAGS.join(',');
|
|
565
|
+
const walker = document.createTreeWalker(container, 4 /* NodeFilter.SHOW_TEXT */);
|
|
566
|
+
const textNodes = [];
|
|
567
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
568
|
+
if (node.nodeValue.includes('$') && !node.parentElement?.closest(ignored)) textNodes.push(node);
|
|
569
|
+
}
|
|
570
|
+
for (const node of textNodes) {
|
|
571
|
+
const parts = splitAtLiteralDollars(node.nodeValue);
|
|
572
|
+
if (!parts.some((part) => part.literal)) continue;
|
|
573
|
+
const fragment = document.createDocumentFragment();
|
|
574
|
+
for (const part of parts) {
|
|
575
|
+
if (!part.literal) {
|
|
576
|
+
fragment.appendChild(document.createTextNode(part.text));
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const span = document.createElement('span');
|
|
580
|
+
span.className = LITERAL_DOLLAR_CLASS;
|
|
581
|
+
span.textContent = part.text;
|
|
582
|
+
fragment.appendChild(span);
|
|
583
|
+
}
|
|
584
|
+
node.parentNode.replaceChild(fragment, node);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
553
588
|
function renderMathInHtml(html) {
|
|
554
589
|
const renderMathInElement = globalThis.renderMathInElement;
|
|
555
590
|
if (typeof renderMathInElement !== 'function' || !globalThis.document?.createElement) return html;
|
|
@@ -557,6 +592,7 @@ function renderMathInHtml(html) {
|
|
|
557
592
|
const container = document.createElement('div');
|
|
558
593
|
container.innerHTML = html;
|
|
559
594
|
try {
|
|
595
|
+
isolateLiteralDollars(container);
|
|
560
596
|
renderMathInElement(container, {
|
|
561
597
|
delimiters: [
|
|
562
598
|
{ left: '$$', right: '$$', display: true },
|
|
@@ -564,7 +600,8 @@ function renderMathInHtml(html) {
|
|
|
564
600
|
{ left: '\\(', right: '\\)', display: false },
|
|
565
601
|
{ left: '$', right: '$', display: false },
|
|
566
602
|
],
|
|
567
|
-
ignoredTags:
|
|
603
|
+
ignoredTags: MATH_IGNORED_TAGS,
|
|
604
|
+
ignoredClasses: [LITERAL_DOLLAR_CLASS],
|
|
568
605
|
throwOnError: false,
|
|
569
606
|
strict: 'ignore',
|
|
570
607
|
trust: false,
|
|
@@ -41,9 +41,11 @@ import { refreshPushSettingsSection } from './push-settings.js';
|
|
|
41
41
|
import { selectSettingsTab } from './settings-tabs.js';
|
|
42
42
|
import { refreshClaudeAuthSection } from './claude-auth-ui.js';
|
|
43
43
|
import { refreshGrokAuthSection } from './grok-auth-ui.js';
|
|
44
|
+
import { openClaudeCloudSettingsSection } from './claude-cloud-settings-ui.js';
|
|
44
45
|
import { refreshCliInstallSections } from './cli-install-ui.js';
|
|
45
46
|
import { refreshUpdateSection } from './update-ui.js';
|
|
46
47
|
import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
|
|
48
|
+
import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
|
|
47
49
|
import {
|
|
48
50
|
getPreviews,
|
|
49
51
|
renderPreviewRowsInto,
|
|
@@ -437,7 +439,7 @@ function ensureClaudeSettingsInputTracking() {
|
|
|
437
439
|
}
|
|
438
440
|
|
|
439
441
|
function setClaudeSettingsControlsDisabled(disabled) {
|
|
440
|
-
for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-save-btn']) {
|
|
442
|
+
for (const id of ['claude-model-input', 'claude-enabled-toggle', 'claude-attribution-select', 'claude-save-btn']) {
|
|
441
443
|
const element = document.getElementById(id);
|
|
442
444
|
if (element) element.disabled = disabled;
|
|
443
445
|
}
|
|
@@ -450,10 +452,15 @@ export function applyClaudeSettingsState(settings = {}, { resetInputs = false }
|
|
|
450
452
|
enabled: settings?.enabled === true,
|
|
451
453
|
model: String(settings?.model || claudeSettingsState.model || 'claude-sonnet-5').trim() || 'claude-sonnet-5',
|
|
452
454
|
models: Array.isArray(settings?.models) ? settings.models : claudeSettingsState.models,
|
|
455
|
+
attributionMode: ['oar', 'vanilla', 'off'].includes(settings?.attributionMode) ? settings.attributionMode : (claudeSettingsState.attributionMode || 'oar'),
|
|
456
|
+
attributionExample: String(settings?.attributionExample || claudeSettingsState.attributionExample || '').trim(),
|
|
453
457
|
};
|
|
454
458
|
const modelInput = document.getElementById('claude-model-input');
|
|
455
459
|
const toggle = document.getElementById('claude-enabled-toggle');
|
|
456
460
|
const status = document.getElementById('claude-settings-status');
|
|
461
|
+
const attributionSelect = document.getElementById('claude-attribution-select');
|
|
462
|
+
if (attributionSelect && (!claudeSettingsInputsDirty || resetInputs)) attributionSelect.value = claudeSettingsState.attributionMode;
|
|
463
|
+
renderClaudeAttributionExample(attributionSelect?.value || claudeSettingsState.attributionMode);
|
|
457
464
|
if (modelInput && (!claudeSettingsInputsDirty || resetInputs)) modelInput.value = claudeSettingsState.model;
|
|
458
465
|
if (resetInputs) claudeSettingsInputsDirty = false;
|
|
459
466
|
if (toggle) {
|
|
@@ -486,14 +493,36 @@ async function syncClaudeSettingsInputs() {
|
|
|
486
493
|
}
|
|
487
494
|
}
|
|
488
495
|
|
|
496
|
+
// What commits will carry for the chosen mode: the example trailer the relay
|
|
497
|
+
// rendered for the default model, Claude Code's own line, or nothing.
|
|
498
|
+
export function claudeAttributionExampleText(mode, example = '') {
|
|
499
|
+
if (mode === 'vanilla') return 'Commits keep Claude Code\'s own attribution (Co-Authored-By: Claude … <noreply@anthropic.com>).';
|
|
500
|
+
if (mode === 'off') return 'Commits and pull requests carry no attribution line.';
|
|
501
|
+
const trailer = String(example || '').trim();
|
|
502
|
+
return trailer ? `Commits end with: ${trailer}` : 'Commits end with: Co-authored-by: Open Agent Relay (<model>) <no-reply@oar.sh>';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function renderClaudeAttributionExample(mode) {
|
|
506
|
+
const example = document.getElementById('claude-attribution-example');
|
|
507
|
+
if (!example) return;
|
|
508
|
+
example.textContent = claudeAttributionExampleText(mode, claudeSettingsState.attributionExample);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
window.previewClaudeAttribution = (mode) => {
|
|
512
|
+
claudeSettingsInputsDirty = true;
|
|
513
|
+
renderClaudeAttributionExample(mode);
|
|
514
|
+
};
|
|
515
|
+
|
|
489
516
|
export async function saveClaudeSettings() {
|
|
490
517
|
if (claudeSettingsUpdateInFlight) return;
|
|
491
518
|
const modelInput = document.getElementById('claude-model-input');
|
|
492
519
|
const model = String(modelInput?.value || '').trim() || 'claude-sonnet-5';
|
|
520
|
+
const attributionSelect = document.getElementById('claude-attribution-select');
|
|
521
|
+
const attributionMode = ['oar', 'vanilla', 'off'].includes(attributionSelect?.value) ? attributionSelect.value : undefined;
|
|
493
522
|
claudeSettingsUpdateInFlight = true;
|
|
494
523
|
setClaudeSettingsControlsDisabled(true);
|
|
495
524
|
try {
|
|
496
|
-
const result = await updateClaudeSettings({ model });
|
|
525
|
+
const result = await updateClaudeSettings({ model, attributionMode });
|
|
497
526
|
if (!result) throw new Error('Failed to save Claude settings.');
|
|
498
527
|
applyClaudeSettingsState(result, { resetInputs: true });
|
|
499
528
|
showTransientRelayNotice(
|
|
@@ -1655,6 +1684,7 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1655
1684
|
ensureClaudeSettingsInputTracking();
|
|
1656
1685
|
void syncClaudeSettingsInputs();
|
|
1657
1686
|
void refreshClaudeAuthSection();
|
|
1687
|
+
void openClaudeCloudSettingsSection();
|
|
1658
1688
|
void refreshGrokAuthSection();
|
|
1659
1689
|
// Forced: the cached rows can be 30s stale, and a CLI installed from a shell
|
|
1660
1690
|
// in the meantime should show up the moment the panel opens. Sockets keep it
|
|
@@ -1678,6 +1708,7 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1678
1708
|
void refreshPushSettingsSection();
|
|
1679
1709
|
void refreshPreviewsSection();
|
|
1680
1710
|
void refreshRemoteRelaysSection();
|
|
1711
|
+
void refreshAgentSessionsSection();
|
|
1681
1712
|
void refreshFeatureFlagsSection();
|
|
1682
1713
|
void refreshUpdateSection();
|
|
1683
1714
|
modal?.classList.add('visible');
|
|
@@ -4,7 +4,7 @@ const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
|
|
|
4
4
|
const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'notifications', 'features'];
|
|
5
5
|
// Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
|
|
6
6
|
// is also the sub-tab a first-time visitor lands on.
|
|
7
|
-
const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'grok', 'cursor'];
|
|
7
|
+
const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor'];
|
|
8
8
|
|
|
9
9
|
let settingsTabsBound = false;
|
|
10
10
|
|