@oar-sh/oar 0.9.9 → 0.9.11
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 +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ⋯ → About OAR: the menu entry's label and the modal (about-view.mjs builds
|
|
2
|
+
// its HTML from GET /api/about).
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
closeSummaryModal,
|
|
6
|
+
escHtml,
|
|
7
|
+
getUpdateState,
|
|
8
|
+
openSummaryModal,
|
|
9
|
+
renderSummaryModalContent,
|
|
10
|
+
setSummaryModalLoading,
|
|
11
|
+
} from './store.js';
|
|
12
|
+
import { apiFetchOrThrow } from './api-client.js';
|
|
13
|
+
import { openSettingsModal } from './settings-modal.js';
|
|
14
|
+
import { aboutMenuLabel, buildAboutModalHtml } from './about-view.mjs';
|
|
15
|
+
|
|
16
|
+
const ABOUT_KIND = 'about';
|
|
17
|
+
const ABOUT_TITLE = 'About OAR';
|
|
18
|
+
|
|
19
|
+
/** The entry carries the running version; /api/status brings it on load. */
|
|
20
|
+
export function syncAboutMenuLabel(version = getUpdateState()?.runningVersion) {
|
|
21
|
+
const button = document.getElementById('chat-menu-about');
|
|
22
|
+
if (button) button.textContent = aboutMenuLabel(version);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bindAboutModalActions() {
|
|
26
|
+
const body = document.getElementById('summary-modal-body');
|
|
27
|
+
const button = body?.querySelector('[data-about-open-settings]');
|
|
28
|
+
if (!button) return;
|
|
29
|
+
button.addEventListener('click', (event) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
closeSummaryModal();
|
|
32
|
+
openSettingsModal('general');
|
|
33
|
+
document.getElementById('update-auto-check-toggle')?.scrollIntoView?.({ block: 'center' });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function loadAboutAndRender() {
|
|
38
|
+
const about = await apiFetchOrThrow('/api/about');
|
|
39
|
+
if (!about) throw new Error('The relay is not reachable right now.');
|
|
40
|
+
renderSummaryModalContent({
|
|
41
|
+
title: ABOUT_TITLE,
|
|
42
|
+
subtitle: about.version ? `Version ${about.version}` : '',
|
|
43
|
+
bodyHtml: buildAboutModalHtml(about),
|
|
44
|
+
refresh: loadAboutAndRender,
|
|
45
|
+
kind: ABOUT_KIND,
|
|
46
|
+
});
|
|
47
|
+
bindAboutModalActions();
|
|
48
|
+
syncAboutMenuLabel(about.version);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function showAbout() {
|
|
52
|
+
openSummaryModal({
|
|
53
|
+
title: ABOUT_TITLE,
|
|
54
|
+
subtitle: 'Loading…',
|
|
55
|
+
bodyHtml: '<div class="summary-loading">Reading the relay…</div>',
|
|
56
|
+
refresh: loadAboutAndRender,
|
|
57
|
+
kind: ABOUT_KIND,
|
|
58
|
+
});
|
|
59
|
+
setSummaryModalLoading(true);
|
|
60
|
+
try {
|
|
61
|
+
await loadAboutAndRender();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
renderSummaryModalContent({
|
|
64
|
+
title: ABOUT_TITLE,
|
|
65
|
+
subtitle: 'Unable to load',
|
|
66
|
+
bodyHtml: `<div class="summary-error">${escHtml(error?.message || 'Unknown error')}</div>`,
|
|
67
|
+
refresh: loadAboutAndRender,
|
|
68
|
+
kind: ABOUT_KIND,
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
setSummaryModalLoading(false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// The About OAR modal, as HTML from a GET /api/about payload. No DOM here;
|
|
2
|
+
// about-ui.js opens the modal and wires it.
|
|
3
|
+
|
|
4
|
+
export const OAR_REPO_URL = 'https://github.com/oar-sh/oar';
|
|
5
|
+
export const OAR_SITE_URL = 'https://oar.sh';
|
|
6
|
+
export const OAR_RELEASES_URL = 'https://github.com/oar-sh/oar/releases';
|
|
7
|
+
|
|
8
|
+
const STATE_LABELS = Object.freeze({
|
|
9
|
+
online: 'online',
|
|
10
|
+
offline: 'offline',
|
|
11
|
+
idle: 'idle',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const USAGE_LABELS = Object.freeze({
|
|
15
|
+
github: 'GitHub Copilot',
|
|
16
|
+
cursor: 'Cursor',
|
|
17
|
+
grok: 'Grok',
|
|
18
|
+
'claude-cloud': 'Claude Cloud',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function esc(value) {
|
|
22
|
+
return String(value ?? '')
|
|
23
|
+
.replace(/&/g, '&')
|
|
24
|
+
.replace(/</g, '<')
|
|
25
|
+
.replace(/>/g, '>')
|
|
26
|
+
.replace(/"/g, '"')
|
|
27
|
+
.replace(/'/g, ''');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function finite(value) {
|
|
31
|
+
const number = Number(value);
|
|
32
|
+
return Number.isFinite(number) ? number : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function aboutMenuLabel(version) {
|
|
36
|
+
const text = String(version || '').trim();
|
|
37
|
+
return text && text !== '0.0.0' ? `ℹ️ About OAR · ${text}` : 'ℹ️ About OAR';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatCount(value) {
|
|
41
|
+
const number = finite(value);
|
|
42
|
+
return number === null ? '–' : Math.round(number).toLocaleString('en-US');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Tokens and other large amounts: 1 234, 12.3 k, 4.56 M. */
|
|
46
|
+
export function formatAmount(value) {
|
|
47
|
+
const number = finite(value);
|
|
48
|
+
if (number === null) return '–';
|
|
49
|
+
if (Math.abs(number) >= 1e9) return `${(number / 1e9).toFixed(2)} B`;
|
|
50
|
+
if (Math.abs(number) >= 1e6) return `${(number / 1e6).toFixed(2)} M`;
|
|
51
|
+
if (Math.abs(number) >= 1e4) return `${(number / 1e3).toFixed(1)} k`;
|
|
52
|
+
return Number.isInteger(number) ? number.toLocaleString('en-US') : number.toFixed(2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatUsd(value) {
|
|
56
|
+
const number = finite(value);
|
|
57
|
+
return number === null ? '–' : `USD ${number.toFixed(2)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatBytes(value) {
|
|
61
|
+
const number = finite(value);
|
|
62
|
+
if (number === null || number < 0) return '–';
|
|
63
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
64
|
+
let size = number;
|
|
65
|
+
let unit = 0;
|
|
66
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
67
|
+
size /= 1024;
|
|
68
|
+
unit += 1;
|
|
69
|
+
}
|
|
70
|
+
return unit === 0 ? `${Math.round(size)} B` : `${size.toFixed(size >= 100 ? 0 : 1)} ${units[unit]}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatUptime(seconds) {
|
|
74
|
+
const total = finite(seconds);
|
|
75
|
+
if (total === null || total < 0) return '–';
|
|
76
|
+
const days = Math.floor(total / 86_400);
|
|
77
|
+
const hours = Math.floor((total % 86_400) / 3_600);
|
|
78
|
+
const minutes = Math.floor((total % 3_600) / 60);
|
|
79
|
+
if (days > 0) return `${days} d ${hours} h`;
|
|
80
|
+
if (hours > 0) return `${hours} h ${minutes} min`;
|
|
81
|
+
if (minutes > 0) return `${minutes} min`;
|
|
82
|
+
return `${Math.floor(total)} s`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function defaultFormatDateTime(iso) {
|
|
86
|
+
const at = Date.parse(iso);
|
|
87
|
+
if (!Number.isFinite(at)) return '–';
|
|
88
|
+
return new Date(at).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function defaultFormatDate(iso) {
|
|
92
|
+
const at = Date.parse(iso);
|
|
93
|
+
if (!Number.isFinite(at)) return '–';
|
|
94
|
+
return new Date(at).toLocaleDateString(undefined, { dateStyle: 'medium' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function row(label, value, { hint = '', title = '' } = {}) {
|
|
98
|
+
return `<tr><td><div class="plan-usage-detail-label"><span>${esc(label)}</span>${hint ? `<span class="plan-usage-detail-hint">${esc(hint)}</span>` : ''}</div></td>`
|
|
99
|
+
+ `<td class="plan-usage-detail-value"${title ? ` title="${esc(title)}"` : ''}>${value}</td></tr>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function section(id, title, inner, { subtitle = '' } = {}) {
|
|
103
|
+
return `<section class="plan-usage-card about-section" data-about-section="${esc(id)}">`
|
|
104
|
+
+ `<div class="plan-usage-card-head"><div class="plan-usage-card-titles"><span class="plan-usage-card-title">${esc(title)}</span>`
|
|
105
|
+
+ `${subtitle ? `<span class="plan-usage-account">${esc(subtitle)}</span>` : ''}</div></div>${inner}</section>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function table(rows) {
|
|
109
|
+
return rows.length ? `<table class="plan-usage-detail-table"><tbody>${rows.join('')}</tbody></table>` : '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function note(text) {
|
|
113
|
+
return `<div class="plan-usage-detail-note">${text}</div>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function externalLink(href, text) {
|
|
117
|
+
return `<a class="plan-usage-link" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function providerDetails(provider) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
if (provider.engine === 'extension') parts.push('extension engine');
|
|
123
|
+
if (provider.engine === 'sdk') parts.push('SDK engine');
|
|
124
|
+
if (provider.cliVersion) parts.push(`CLI ${provider.cliVersion}`);
|
|
125
|
+
if (provider.sdkVersion) parts.push(`SDK ${provider.sdkVersion}`);
|
|
126
|
+
return parts.join(' · ');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function providerState(provider) {
|
|
130
|
+
const state = STATE_LABELS[provider.state] ? provider.state : 'idle';
|
|
131
|
+
const workers = finite(provider.runningWorkers) || 0;
|
|
132
|
+
const count = workers > 0 ? ` · ${workers} ${workers === 1 ? 'worker' : 'workers'}` : '';
|
|
133
|
+
return `<span class="about-state" data-state="${esc(state)}">${esc(STATE_LABELS[state])}</span>${esc(count)}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function usageRows(id, usage) {
|
|
137
|
+
if (id === 'github') {
|
|
138
|
+
return [row('Premium requests', esc(formatAmount(usage.premiumRequests)), { hint: `over ${formatCount(usage.turns)} replies` })];
|
|
139
|
+
}
|
|
140
|
+
if (id === 'cursor') {
|
|
141
|
+
return [
|
|
142
|
+
row('Tokens', esc(formatAmount(usage.totalTokens)), {
|
|
143
|
+
hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out · ${formatAmount((finite(usage.cacheReadTokens) || 0) + (finite(usage.cacheWriteTokens) || 0))} cache`,
|
|
144
|
+
}),
|
|
145
|
+
row('Cost', esc(formatUsd(usage.rawCostUsd)), { hint: `model price over ${formatCount(usage.runs)} runs; charged ${formatUsd(usage.chargedUsd)}` }),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
if (id === 'grok') {
|
|
149
|
+
return [
|
|
150
|
+
row('Tokens', esc(formatAmount(usage.totalTokens)), { hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out` }),
|
|
151
|
+
row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.turns)} turns` }),
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
if (id === 'claude-cloud') {
|
|
155
|
+
return [row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.sessions)} cloud sessions` })];
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildUpdateSection(update, formatDateTime) {
|
|
161
|
+
if (update.checkEnabled) {
|
|
162
|
+
if (update.available && update.latestVersion) {
|
|
163
|
+
return section('update', 'Updates', note(`Latest: ${esc(update.latestVersion)} · ${externalLink(update.notesUrl || OAR_RELEASES_URL, 'release notes')}`));
|
|
164
|
+
}
|
|
165
|
+
const checked = update.lastCheckedAt ? `, checked ${esc(formatDateTime(update.lastCheckedAt))}` : '';
|
|
166
|
+
return section('update', 'Updates', note(update.lastCheckedAt ? `Up to date${checked}.` : 'Not checked yet.'));
|
|
167
|
+
}
|
|
168
|
+
if (update.checkKilled) {
|
|
169
|
+
return section('update', 'Updates', note('Update checks are turned off on this relay (OAR_NO_UPDATE_CHECK).'));
|
|
170
|
+
}
|
|
171
|
+
return section('update', 'Updates', note(
|
|
172
|
+
'Update checks are off. Turn on <button type="button" class="plan-usage-link about-inline-button" data-about-open-settings>Settings › General › Check for updates automatically</button> to see the latest version here.',
|
|
173
|
+
));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {object} about the GET /api/about payload
|
|
178
|
+
* @param {object} [options]
|
|
179
|
+
* @param {(iso: string) => string} [options.formatDateTime]
|
|
180
|
+
* @param {(iso: string) => string} [options.formatDate]
|
|
181
|
+
*/
|
|
182
|
+
export function buildAboutModalHtml(about = {}, {
|
|
183
|
+
formatDateTime = defaultFormatDateTime,
|
|
184
|
+
formatDate = defaultFormatDate,
|
|
185
|
+
} = {}) {
|
|
186
|
+
const sections = [];
|
|
187
|
+
|
|
188
|
+
const commit = String(about.commit || '').trim();
|
|
189
|
+
const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
|
|
190
|
+
sections.push(section('build', 'OAR', table([
|
|
191
|
+
row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
|
|
192
|
+
...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
|
|
193
|
+
row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
|
|
194
|
+
row('Uptime', esc(formatUptime(about.uptimeSeconds))),
|
|
195
|
+
])));
|
|
196
|
+
|
|
197
|
+
const providers = Array.isArray(about.providers) ? about.providers : [];
|
|
198
|
+
sections.push(section('providers', 'Providers', providers.length
|
|
199
|
+
? table(providers.map((provider) => row(provider.label || provider.id, providerState(provider), { hint: providerDetails(provider) })))
|
|
200
|
+
+ (providers.some((provider) => provider.state === 'idle')
|
|
201
|
+
? note('Idle: on, with no worker running right now; one starts with the next turn.')
|
|
202
|
+
: '')
|
|
203
|
+
: note('No provider is turned on.')));
|
|
204
|
+
|
|
205
|
+
const stats = about.stats || {};
|
|
206
|
+
const turnsByProvider = Array.isArray(stats.turnsByProvider) ? stats.turnsByProvider : [];
|
|
207
|
+
const archived = finite(stats.archivedConversations) || 0;
|
|
208
|
+
sections.push(section('activity', 'Activity', table([
|
|
209
|
+
row('Conversations', esc(formatCount(stats.conversations)), archived ? { hint: `${formatCount(archived)} archived` } : {}),
|
|
210
|
+
row('Turns', esc(formatCount(stats.turns)), { hint: 'replies written by an agent' }),
|
|
211
|
+
row('Messages', esc(formatCount(stats.messages))),
|
|
212
|
+
...turnsByProvider.map((entry) => row(`Turns on ${entry.label || entry.id}`, esc(formatCount(entry.turns)))),
|
|
213
|
+
]), { subtitle: stats.firstConversationAt ? `since ${formatDate(stats.firstConversationAt)}` : '' }));
|
|
214
|
+
|
|
215
|
+
const usage = about.usage && typeof about.usage === 'object' ? about.usage : {};
|
|
216
|
+
const usageIds = Object.keys(USAGE_LABELS).filter((id) => usage[id]);
|
|
217
|
+
sections.push(section('usage', 'Usage', (usageIds.length
|
|
218
|
+
? usageIds.map((id) => `<div class="about-usage-provider">${esc(USAGE_LABELS[id])}</div>${table(usageRows(id, usage[id]))}`).join('')
|
|
219
|
+
: note('No provider has reported usage yet.'))
|
|
220
|
+
+ note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session. Claude Code and OpenAI turns keep no usage here.')));
|
|
221
|
+
|
|
222
|
+
const relay = about.relay || {};
|
|
223
|
+
sections.push(section('relay', 'Relay', table([
|
|
224
|
+
row('Uptime', esc(formatUptime(relay.uptimeSeconds))),
|
|
225
|
+
row('Database', esc(formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
|
|
226
|
+
row('Running workers', esc(formatCount(relay.runningWorkers))),
|
|
227
|
+
])));
|
|
228
|
+
|
|
229
|
+
sections.push(buildUpdateSection(about.update || {}, formatDateTime));
|
|
230
|
+
|
|
231
|
+
sections.push(`<div class="about-links" data-about-section="links">${externalLink(OAR_REPO_URL, 'github.com/oar-sh/oar')}${externalLink(OAR_SITE_URL, 'oar.sh')}</div>`);
|
|
232
|
+
|
|
233
|
+
return `<div class="about-modal">${sections.join('')}</div>`;
|
|
234
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
refreshWorkspaceRootHints,
|
|
20
20
|
} from './api-client.js';
|
|
21
21
|
import { isSuspendHostActionVisible } from './settings-modal.js';
|
|
22
|
+
import { describeRelayRestartError } from './relay-restart-error.mjs';
|
|
22
23
|
import {
|
|
23
24
|
applyHostSuspendState,
|
|
24
25
|
describeHostSuspendBlocker,
|
|
@@ -311,10 +312,17 @@ export async function confirmEmptyQueue() {
|
|
|
311
312
|
emptyQueueInFlight = true;
|
|
312
313
|
setSummaryModalLoading(true);
|
|
313
314
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
let result = null;
|
|
316
|
+
try {
|
|
317
|
+
result = await requestQueueEmpty({
|
|
318
|
+
reason: 'manual-empty-queue',
|
|
319
|
+
requestedBy: 'localhost-api',
|
|
320
|
+
});
|
|
321
|
+
} catch (error) {
|
|
322
|
+
closeSummaryModal();
|
|
323
|
+
alert(`Failed to empty queue: ${error?.message || 'unknown error'}`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
318
326
|
closeSummaryModal();
|
|
319
327
|
if (!result?.ok) {
|
|
320
328
|
alert('Failed to empty queue');
|
|
@@ -334,23 +342,32 @@ export async function confirmEmptyQueue() {
|
|
|
334
342
|
}
|
|
335
343
|
}
|
|
336
344
|
|
|
345
|
+
/** A failed restart request: a refusal as an alert, no answer as a notice. */
|
|
346
|
+
export function showRelayRestartRequestError(error) {
|
|
347
|
+
const { kind, text } = describeRelayRestartError(error);
|
|
348
|
+
if (kind === 'refused') alert(text);
|
|
349
|
+
else showTransientRelayNotice(text, 9000);
|
|
350
|
+
}
|
|
351
|
+
|
|
337
352
|
export async function confirmRestartWebRelay() {
|
|
338
353
|
if (restartRelayInFlight) return;
|
|
339
354
|
restartRelayInFlight = true;
|
|
340
355
|
setSummaryModalLoading(true);
|
|
341
356
|
try {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
357
|
+
let result = null;
|
|
358
|
+
try {
|
|
359
|
+
result = await requestRelayRestart({
|
|
360
|
+
reason: 'manual-restart',
|
|
361
|
+
requestedBy: 'localhost-api',
|
|
362
|
+
restart: true,
|
|
363
|
+
});
|
|
364
|
+
} catch (error) {
|
|
365
|
+
closeSummaryModal();
|
|
366
|
+
showRelayRestartRequestError(error);
|
|
351
367
|
return;
|
|
352
368
|
}
|
|
353
|
-
|
|
369
|
+
closeSummaryModal();
|
|
370
|
+
if (!result?.ok) {
|
|
354
371
|
alert('Failed to queue relay restart');
|
|
355
372
|
return;
|
|
356
373
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Agent-to-agent exchanges in the transcript: a prompt another agent sent this
|
|
2
|
+
// session (another relay's agent, or an agent on this relay) folds into ONE
|
|
3
|
+
// row, "note from win-test · report builder · answered below", which opens to
|
|
4
|
+
// the prompt as it renders otherwise.
|
|
5
|
+
//
|
|
6
|
+
// DOM-free on purpose, like relay-event-view.mjs: conversation-view.js reads
|
|
7
|
+
// the rows off the transcript, plans with this module and applies the plan.
|
|
8
|
+
// Presentation only: the transcript data is never touched.
|
|
9
|
+
//
|
|
10
|
+
// Nothing addressed to the user is folded away. The fold covers the agent's
|
|
11
|
+
// prompt and nothing else: this session's reply to it can carry a line for
|
|
12
|
+
// the user, so the reply, the live bubble of the running turn and every card
|
|
13
|
+
// of the turn always show, as they do for a message of the user's own. The
|
|
14
|
+
// row still says how the turn stands.
|
|
15
|
+
//
|
|
16
|
+
// One prompt is left as it is: an agent's prompt that was steered into a
|
|
17
|
+
// running turn. It renders as part of that turn's flow (steered-turn-merge.mjs:
|
|
18
|
+
// the dashed bubble, the marker attached under it), and a row in its place
|
|
19
|
+
// would break that flow.
|
|
20
|
+
|
|
21
|
+
import { isRelayEventMessage } from './relay-event-view.mjs';
|
|
22
|
+
import { relayErrorCodeFromText } from './relay-error-ctas.mjs';
|
|
23
|
+
import { isLocalAgentOrigin, localAgentOriginModel, normalizeRemoteOrigin } from './remote-relay-origin-view.mjs';
|
|
24
|
+
|
|
25
|
+
export const AGENT_EXCHANGE_HEAD_CLASS = 'agent-exchange-head';
|
|
26
|
+
export const AGENT_EXCHANGE_CLOSED_CLASS = 'agent-exchange-closed';
|
|
27
|
+
|
|
28
|
+
export const AGENT_EXCHANGE_STATUS_TEXT = Object.freeze({
|
|
29
|
+
working: 'working…',
|
|
30
|
+
replied: 'answered below',
|
|
31
|
+
failed: 'failed',
|
|
32
|
+
stopped: 'stopped',
|
|
33
|
+
none: 'no reply yet',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The code a turn the user stopped ends with (shared/relay-stop-failure.mjs).
|
|
37
|
+
const STOPPED_ERROR_CODE = 'turn-aborted';
|
|
38
|
+
// A steer's settle markers (steered-turn-merge.mjs): the message was consumed
|
|
39
|
+
// by another turn, folded into it or stopped with it.
|
|
40
|
+
const STEER_MARKER_KINDS = new Set(['folded', 'stopped']);
|
|
41
|
+
|
|
42
|
+
function defaultEscape(value) {
|
|
43
|
+
return String(value ?? '')
|
|
44
|
+
.replace(/&/g, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/'/g, ''');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function oneLine(value) {
|
|
52
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True for an `origin` that names an agent: one on this relay or another relay's. */
|
|
56
|
+
export function isAgentOrigin(origin) {
|
|
57
|
+
return isLocalAgentOrigin(origin) || !!normalizeRemoteOrigin(origin);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A prompt an agent sent: a user row with an agent's origin, never a relay event. */
|
|
61
|
+
export function isAgentPromptMessage(message) {
|
|
62
|
+
return message?.role === 'user' && !isRelayEventMessage(message) && isAgentOrigin(message?.origin);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How a reply ended, from its text: 'failed' or 'stopped' for a failure note
|
|
67
|
+
* of the relay, '' for an answer. The note goes below what the turn had
|
|
68
|
+
* written before it failed, so only the last paragraph is read.
|
|
69
|
+
*/
|
|
70
|
+
export function replyOutcome(text) {
|
|
71
|
+
const paragraphs = String(text ?? '').trim().split(/\r?\n\s*\r?\n/);
|
|
72
|
+
const code = relayErrorCodeFromText(paragraphs[paragraphs.length - 1]);
|
|
73
|
+
if (!code) return '';
|
|
74
|
+
return code === STOPPED_ERROR_CODE ? 'stopped' : 'failed';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What the plan needs of one message, built when its row is: an agent
|
|
79
|
+
* prompt's origin, an assistant row's kind, the prompt it answers and how it
|
|
80
|
+
* ended.
|
|
81
|
+
*/
|
|
82
|
+
export function agentExchangeRowOf(message, { id = message?.id } = {}) {
|
|
83
|
+
const role = message?.role === 'assistant' ? 'assistant' : (message?.role === 'user' ? 'user' : '');
|
|
84
|
+
return {
|
|
85
|
+
id: String(id ?? '').trim(),
|
|
86
|
+
role,
|
|
87
|
+
relayEvent: isRelayEventMessage(message),
|
|
88
|
+
origin: isAgentPromptMessage(message) ? message.origin : null,
|
|
89
|
+
kind: String(message?.kind || '').trim(),
|
|
90
|
+
sourceMessageId: role === 'assistant' ? String(message?.sourceMessageId || '').trim() : '',
|
|
91
|
+
outcome: role === 'assistant' ? replyOutcome(message?.text) : '',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The plan for a transcript: one entry per agent prompt, in transcript order.
|
|
97
|
+
*
|
|
98
|
+
* `rows` are the transcript's rows in order (agentExchangeRowOf, plus
|
|
99
|
+
* `steered` for a user row steered-turn-merge.mjs classed as steered), and
|
|
100
|
+
* the live bubble of a running turn as `{ id: <its message id>, role:
|
|
101
|
+
* 'assistant', live: true }`. `runningIds` names turns that run without a
|
|
102
|
+
* live bubble on the page.
|
|
103
|
+
*
|
|
104
|
+
* Each entry: `{ promptId, prompt, status, fold }`. The prompt's row is the
|
|
105
|
+
* only one a fold covers; the replies and the live bubble are read for the
|
|
106
|
+
* status alone. `fold` is false for a prompt steered into a running turn.
|
|
107
|
+
*/
|
|
108
|
+
export function planAgentExchangeFolds(rows, { runningIds = [] } = {}) {
|
|
109
|
+
const list = (Array.isArray(rows) ? rows : []).filter((row) => row && String(row.id || '').trim());
|
|
110
|
+
const running = new Set(Array.from(runningIds || [], (value) => String(value ?? '').trim()).filter(Boolean));
|
|
111
|
+
|
|
112
|
+
const turns = [];
|
|
113
|
+
const turnById = new Map();
|
|
114
|
+
for (const row of list) {
|
|
115
|
+
if (row.role !== 'user' || !row.origin || turnById.has(row.id)) continue;
|
|
116
|
+
const turn = { prompt: row, replies: [], live: false };
|
|
117
|
+
turns.push(turn);
|
|
118
|
+
turnById.set(row.id, turn);
|
|
119
|
+
}
|
|
120
|
+
for (const row of list) {
|
|
121
|
+
if (row.role !== 'assistant') continue;
|
|
122
|
+
const turn = turnById.get(row.live ? row.id : row.sourceMessageId);
|
|
123
|
+
if (!turn) continue;
|
|
124
|
+
if (row.live) turn.live = true;
|
|
125
|
+
else turn.replies.push(row);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return turns.map((turn) => {
|
|
129
|
+
const markers = turn.replies.filter((row) => STEER_MARKER_KINDS.has(row.kind));
|
|
130
|
+
// An answer, or the marker that the answer is on a continuation row
|
|
131
|
+
// further down.
|
|
132
|
+
const answers = turn.replies.filter((row) => !STEER_MARKER_KINDS.has(row.kind));
|
|
133
|
+
const last = answers[answers.length - 1] || null;
|
|
134
|
+
let status = 'none';
|
|
135
|
+
if (last) status = last.outcome || 'replied';
|
|
136
|
+
else if (turn.live || running.has(turn.prompt.id)) status = 'working';
|
|
137
|
+
return {
|
|
138
|
+
promptId: turn.prompt.id,
|
|
139
|
+
prompt: turn.prompt,
|
|
140
|
+
status,
|
|
141
|
+
fold: !turn.prompt.steered && !markers.length,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a folded prompt shows open. `choice` is the user's own toggle of
|
|
148
|
+
* it (true opened, false closed, undefined none) and always wins; without
|
|
149
|
+
* one it is open while the turn works and closes by itself once it is
|
|
150
|
+
* replied, failed or stopped.
|
|
151
|
+
*/
|
|
152
|
+
export function agentExchangeFoldOpen(status, choice) {
|
|
153
|
+
if (typeof choice === 'boolean') return choice;
|
|
154
|
+
return status === 'working';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The folded row's words: who sent the prompt (the relay's name; for an agent
|
|
159
|
+
* on this relay the name the origin carries, else "this relay"), from which
|
|
160
|
+
* session (for a local one its current title when `conversations` knows it),
|
|
161
|
+
* and how the turn stands.
|
|
162
|
+
*/
|
|
163
|
+
export function agentExchangeSummary(origin, status, { conversations = null } = {}) {
|
|
164
|
+
const local = localAgentOriginModel(origin, { conversations, linkable: false });
|
|
165
|
+
const remote = local ? null : normalizeRemoteOrigin(origin);
|
|
166
|
+
const from = local ? (oneLine(origin?.relayName) || 'this relay') : (remote?.relayName || 'another relay');
|
|
167
|
+
const title = local ? local.fullTitle : (remote?.conversationTitle || '');
|
|
168
|
+
const known = Object.prototype.hasOwnProperty.call(AGENT_EXCHANGE_STATUS_TEXT, status) ? status : 'none';
|
|
169
|
+
const head = [`note from ${from}`, title].filter(Boolean).join(' · ');
|
|
170
|
+
const statusText = AGENT_EXCHANGE_STATUS_TEXT[known];
|
|
171
|
+
return { from, title, status: known, statusText, head, text: `${head} · ${statusText}` };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The folded row's markup: a <details> whose summary is the whole row. Its
|
|
176
|
+
* body stays empty — the prompt's bubble is its sibling in the prompt's own
|
|
177
|
+
* node, shown while it is open.
|
|
178
|
+
*/
|
|
179
|
+
export function renderAgentExchangeRowHtml(summary, { escHtml = defaultEscape, timeText = '', open = false } = {}) {
|
|
180
|
+
return `<details class="agent-exchange-row agent-exchange-${escHtml(summary?.status || 'none')}"${open ? ' open' : ''}>`
|
|
181
|
+
+ `<summary class="agent-exchange-summary" title="${escHtml(summary?.text)}">`
|
|
182
|
+
+ `<span class="agent-exchange-text">${escHtml(summary?.head)}</span>`
|
|
183
|
+
+ `<span class="agent-exchange-status">${escHtml(summary?.statusText)}</span>`
|
|
184
|
+
+ (timeText ? `<span class="agent-exchange-time">${escHtml(timeText)}</span>` : '')
|
|
185
|
+
+ '</summary>'
|
|
186
|
+
+ '</details>';
|
|
187
|
+
}
|