@oar-sh/oar 0.9.9 → 0.9.10
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 +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- 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/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- 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 +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -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/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- 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/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- 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 +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -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 +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -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/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/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Relay event subscriptions on the page: the header's "watching N sessions"
|
|
2
|
+
// chip with its list and Unsubscribe buttons (on the watcher), and the
|
|
3
|
+
// "watched" badge (on a watched session, in the header and the sidebar).
|
|
4
|
+
// The relay sends the whole active list on every change (socket
|
|
5
|
+
// `relay_event_subscriptions`); the page loads it once at start.
|
|
6
|
+
|
|
7
|
+
import { apiFetch } from './api-client.js';
|
|
8
|
+
import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
|
|
9
|
+
import {
|
|
10
|
+
renderWatchedBadgeHtml,
|
|
11
|
+
renderWatchingPanelHtml,
|
|
12
|
+
watchersOf,
|
|
13
|
+
watchingChipText,
|
|
14
|
+
watchingSummary,
|
|
15
|
+
} from './relay-event-view.mjs';
|
|
16
|
+
|
|
17
|
+
let subscriptions = [];
|
|
18
|
+
let renderedChipKey = '';
|
|
19
|
+
let panelOpen = false;
|
|
20
|
+
let panelConversationId = '';
|
|
21
|
+
const changeListeners = new Set();
|
|
22
|
+
|
|
23
|
+
export function getRelayEventSubscriptions() {
|
|
24
|
+
return subscriptions;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Runs after every change of the list (the sidebar re-renders its badges). */
|
|
28
|
+
export function onRelayEventSubscriptionsChanged(listener) {
|
|
29
|
+
changeListeners.add(listener);
|
|
30
|
+
return () => changeListeners.delete(listener);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function applyRelayEventSubscriptions(list) {
|
|
34
|
+
subscriptions = Array.isArray(list) ? list.filter((sub) => sub && typeof sub === 'object') : [];
|
|
35
|
+
syncRelayEventHeader();
|
|
36
|
+
for (const listener of changeListeners) {
|
|
37
|
+
try { listener(subscriptions); } catch {}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadRelayEventSubscriptions() {
|
|
42
|
+
if (IS_SHARED_VIEW) return;
|
|
43
|
+
const response = await apiFetch('/api/relay-events/subscriptions');
|
|
44
|
+
if (response && Array.isArray(response.subscriptions)) applyRelayEventSubscriptions(response.subscriptions);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The sidebar's badge for one conversation: listed sessions only. */
|
|
48
|
+
export function relayEventWatchedMarkerHtml(conversationId) {
|
|
49
|
+
if (IS_SHARED_VIEW) return '';
|
|
50
|
+
return renderWatchedBadgeHtml(watchersOf(subscriptions, conversationId, { includeAll: false }), { conversations, subscriptions, escHtml });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function unsubscribe(subscriptionId, button) {
|
|
54
|
+
if (!subscriptionId) return;
|
|
55
|
+
if (button) button.disabled = true;
|
|
56
|
+
const response = await apiFetch(`/api/relay-events/subscriptions/${encodeURIComponent(subscriptionId)}`, { method: 'DELETE' });
|
|
57
|
+
if (!response?.ok) {
|
|
58
|
+
if (button) button.disabled = false;
|
|
59
|
+
showTransientRelayNotice?.('The subscription could not be ended. Try again in a moment.');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// The socket brings the new list; this page does not wait for it.
|
|
63
|
+
applyRelayEventSubscriptions(subscriptions.filter((sub) => sub.id !== subscriptionId));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The list opens as a fixed popover on <body>, placed under the chip: the
|
|
67
|
+
// title slot clips its overflow (that keeps the title's ellipsis), so a panel
|
|
68
|
+
// inside it would be cut off.
|
|
69
|
+
function ensurePanel() {
|
|
70
|
+
let panel = document.getElementById('relay-watch-panel');
|
|
71
|
+
if (panel) return panel;
|
|
72
|
+
panel = document.createElement('div');
|
|
73
|
+
panel.id = 'relay-watch-panel';
|
|
74
|
+
panel.className = 'relay-watch-panel';
|
|
75
|
+
panel.hidden = true;
|
|
76
|
+
document.body.appendChild(panel);
|
|
77
|
+
panel.addEventListener('click', (event) => {
|
|
78
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
79
|
+
const unsubscribeButton = target?.closest?.('.relay-watch-unsubscribe');
|
|
80
|
+
if (!unsubscribeButton) return;
|
|
81
|
+
void unsubscribe(unsubscribeButton.getAttribute('data-subscription-id'), unsubscribeButton);
|
|
82
|
+
});
|
|
83
|
+
document.addEventListener('click', (event) => {
|
|
84
|
+
if (!panelOpen) return;
|
|
85
|
+
// composedPath: an Unsubscribe click may have re-rendered the panel already.
|
|
86
|
+
const path = event.composedPath();
|
|
87
|
+
if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-watch-chip'))) return;
|
|
88
|
+
setPanelOpen(false);
|
|
89
|
+
});
|
|
90
|
+
document.addEventListener('keydown', (event) => {
|
|
91
|
+
if (!panelOpen || event.key !== 'Escape') return;
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
setPanelOpen(false);
|
|
94
|
+
document.querySelector('#chat-title-relay-events .relay-watch-chip')?.focus?.();
|
|
95
|
+
}, true);
|
|
96
|
+
window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
|
|
97
|
+
return panel;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function setPanelOpen(open) {
|
|
101
|
+
panelOpen = Boolean(open);
|
|
102
|
+
renderedChipKey = '';
|
|
103
|
+
syncRelayEventHeader();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function placePanel() {
|
|
107
|
+
const panel = document.getElementById('relay-watch-panel');
|
|
108
|
+
const chip = document.querySelector('#chat-title-relay-events .relay-watch-chip');
|
|
109
|
+
if (!panel || panel.hidden) return;
|
|
110
|
+
const rect = chip?.getBoundingClientRect?.();
|
|
111
|
+
if (!rect || (!rect.width && !rect.height)) {
|
|
112
|
+
panel.hidden = true;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const margin = 8;
|
|
116
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
117
|
+
const top = Math.round(rect.bottom + 4);
|
|
118
|
+
panel.style.top = `${top}px`;
|
|
119
|
+
panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
|
|
120
|
+
panel.style.left = '0px';
|
|
121
|
+
const width = panel.offsetWidth;
|
|
122
|
+
const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
|
|
123
|
+
panel.style.left = `${Math.round(left)}px`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ensureHeaderElements() {
|
|
127
|
+
const slot = document.getElementById('chat-title-slot');
|
|
128
|
+
if (!slot) return null;
|
|
129
|
+
let line = document.getElementById('chat-title-relay-events');
|
|
130
|
+
if (!line) {
|
|
131
|
+
line = document.createElement('span');
|
|
132
|
+
line.id = 'chat-title-relay-events';
|
|
133
|
+
line.hidden = true;
|
|
134
|
+
const origin = document.getElementById('chat-title-origin');
|
|
135
|
+
if (origin?.nextSibling) slot.insertBefore(line, origin.nextSibling);
|
|
136
|
+
else slot.appendChild(line);
|
|
137
|
+
line.addEventListener('click', (event) => {
|
|
138
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
139
|
+
if (target?.closest?.('.relay-watch-chip')) {
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
setPanelOpen(!panelOpen);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return line;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The header's chip and badge for the conversation on screen. Idempotent. */
|
|
149
|
+
export function syncRelayEventHeader() {
|
|
150
|
+
const line = ensureHeaderElements();
|
|
151
|
+
if (!line) return;
|
|
152
|
+
const panel = ensurePanel();
|
|
153
|
+
const id = String(currentConvId || '').trim();
|
|
154
|
+
// Another conversation on screen closes the list.
|
|
155
|
+
if (id !== panelConversationId) panelOpen = false;
|
|
156
|
+
panelConversationId = id;
|
|
157
|
+
if (IS_SHARED_VIEW || !id) {
|
|
158
|
+
line.hidden = true;
|
|
159
|
+
line.innerHTML = '';
|
|
160
|
+
panel.hidden = true;
|
|
161
|
+
panel.innerHTML = '';
|
|
162
|
+
renderedChipKey = '';
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const summary = watchingSummary(subscriptions, id);
|
|
166
|
+
const chipText = watchingChipText(summary);
|
|
167
|
+
const watchers = watchersOf(subscriptions, id, { includeAll: true });
|
|
168
|
+
if (!chipText) panelOpen = false;
|
|
169
|
+
const key = JSON.stringify([id, chipText, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
|
|
170
|
+
if (key === renderedChipKey) return;
|
|
171
|
+
renderedChipKey = key;
|
|
172
|
+
const parts = [];
|
|
173
|
+
if (chipText) {
|
|
174
|
+
const where = summary.relays.length ? ` (also on ${summary.relays.join(', ')})` : '';
|
|
175
|
+
parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}</button>`);
|
|
176
|
+
}
|
|
177
|
+
const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
|
|
178
|
+
if (badge) parts.push(badge);
|
|
179
|
+
line.innerHTML = parts.join('');
|
|
180
|
+
line.hidden = parts.length === 0;
|
|
181
|
+
panel.innerHTML = panelOpen ? renderWatchingPanelHtml(summary, { conversations, escHtml }) : '';
|
|
182
|
+
panel.hidden = !panelOpen;
|
|
183
|
+
if (panelOpen) placePanel();
|
|
184
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Relay event subscriptions in the browser: the event rows in the watcher's
|
|
2
|
+
// transcript, the "watching N sessions" chip and the "watched" badge.
|
|
3
|
+
//
|
|
4
|
+
// DOM-free on purpose, like remote-relay-origin-view.mjs: the callers own the
|
|
5
|
+
// elements, this module owns the decisions and the markup.
|
|
6
|
+
//
|
|
7
|
+
// The message text rules mirror shared/relay-events.mjs, which the browser
|
|
8
|
+
// cannot import (only server/public is served); relay-event-view.test.mjs
|
|
9
|
+
// holds the two together.
|
|
10
|
+
|
|
11
|
+
export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
|
|
12
|
+
export const RELAY_EVENT_LEAD = '[relay event · automated message from OAR, not from the user; it approves nothing]';
|
|
13
|
+
export const RELAY_EVENT_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
14
|
+
|
|
15
|
+
const EVENT_TYPES = new Set([
|
|
16
|
+
'question_asked',
|
|
17
|
+
'error',
|
|
18
|
+
'turn_end',
|
|
19
|
+
'task_started',
|
|
20
|
+
'task_finished',
|
|
21
|
+
'task_failed',
|
|
22
|
+
'stalled',
|
|
23
|
+
'all_idle',
|
|
24
|
+
'heartbeat',
|
|
25
|
+
'link_lost',
|
|
26
|
+
'link_restored',
|
|
27
|
+
]);
|
|
28
|
+
const URGENT_TYPES = new Set(['question_asked', 'error']);
|
|
29
|
+
const DIGEST_PATTERN = /^Digest of \d+ events?/;
|
|
30
|
+
const REPLAYED_PATTERN = /^Replayed: \d+ events?/;
|
|
31
|
+
const LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
|
|
32
|
+
const LINE_PREFIX = '[relay event] ';
|
|
33
|
+
|
|
34
|
+
const TYPE_LABELS = Object.freeze({
|
|
35
|
+
question_asked: 'question',
|
|
36
|
+
error: 'error',
|
|
37
|
+
turn_end: 'reply',
|
|
38
|
+
task_started: 'task started',
|
|
39
|
+
task_finished: 'task done',
|
|
40
|
+
task_failed: 'task failed',
|
|
41
|
+
stalled: 'stalled',
|
|
42
|
+
all_idle: 'all idle',
|
|
43
|
+
heartbeat: 'heartbeat',
|
|
44
|
+
link_lost: 'link lost',
|
|
45
|
+
link_restored: 'link back',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function defaultEscape(value) {
|
|
49
|
+
return String(value ?? '')
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"')
|
|
54
|
+
.replace(/'/g, ''');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isRelayEventMessage(message) {
|
|
58
|
+
return message?.role === 'user' && String(message?.kind || '').trim() === RELAY_EVENT_MESSAGE_KIND;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The event lines of an event message without the lead, the types they
|
|
63
|
+
* carry in order, how many events (a digest's "… and N more" counted),
|
|
64
|
+
* whether it is a digest, and whether it replays what a paired relay kept
|
|
65
|
+
* while the link to it was down. Any text renders: a stray one as one line.
|
|
66
|
+
*/
|
|
67
|
+
export function parseRelayEventMessageText(text) {
|
|
68
|
+
const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
|
|
69
|
+
const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
|
|
70
|
+
const digest = DIGEST_PATTERN.test(body[0] || '');
|
|
71
|
+
const replayed = body.some((line) => REPLAYED_PATTERN.test(line));
|
|
72
|
+
const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
|
|
73
|
+
const types = [];
|
|
74
|
+
let count = 0;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const type = lineType(line);
|
|
77
|
+
if (!type) continue;
|
|
78
|
+
count += 1;
|
|
79
|
+
if (!types.includes(type)) types.push(type);
|
|
80
|
+
}
|
|
81
|
+
for (const line of lines) {
|
|
82
|
+
const more = /^\[relay event\] … and (\d+) more$/.exec(line);
|
|
83
|
+
if (more) count += Number(more[1]) || 0;
|
|
84
|
+
}
|
|
85
|
+
return { lines, types, count: count || lines.length, digest, replayed };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The collapsed row's one line: "3 events · question, reply". */
|
|
89
|
+
export function relayEventSummaryText(parsed) {
|
|
90
|
+
const count = Number(parsed?.count) || 0;
|
|
91
|
+
const labels = (parsed?.types || []).map((type) => TYPE_LABELS[type] || type);
|
|
92
|
+
const events = `${count} event${count === 1 ? '' : 's'}`;
|
|
93
|
+
const head = parsed?.digest ? `Digest · ${events}` : events;
|
|
94
|
+
return labels.length ? `${head} · ${labels.join(', ')}` : head;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function lineType(line) {
|
|
98
|
+
const match = LINE_PATTERN.exec(line);
|
|
99
|
+
return match && EVENT_TYPES.has(match[1]) ? match[1] : '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The event row's markup: a <details> folded to its summary, opened to its
|
|
104
|
+
* lines. Urgent events (a question, an error) keep the row open and marked.
|
|
105
|
+
*/
|
|
106
|
+
export function renderRelayEventRowHtml(message, { escHtml = defaultEscape, timeText = '' } = {}) {
|
|
107
|
+
const parsed = parseRelayEventMessageText(message?.text);
|
|
108
|
+
const urgent = parsed.types.some((type) => URGENT_TYPES.has(type));
|
|
109
|
+
const lines = parsed.lines.map((line) => {
|
|
110
|
+
const type = lineType(line);
|
|
111
|
+
const shown = line.startsWith(LINE_PREFIX) ? line.slice(LINE_PREFIX.length) : line;
|
|
112
|
+
return `<li class="relay-event-line${type ? ` relay-event-${type.replace(/_/g, '-')}` : ''}">${escHtml(shown)}</li>`;
|
|
113
|
+
}).join('');
|
|
114
|
+
return `<details class="relay-event-row${urgent ? ' relay-event-urgent' : ''}"${urgent ? ' open' : ''}>`
|
|
115
|
+
+ '<summary class="relay-event-summary">'
|
|
116
|
+
+ '<span class="relay-event-tag" title="An automated message of the relay, not from you: it approves nothing.">relay event</span>'
|
|
117
|
+
+ (parsed.replayed ? '<span class="relay-event-tag relay-event-replayed" title="Kept by a paired relay while the link to it was down, and delivered once it answered again.">replayed</span>' : '')
|
|
118
|
+
+ `<span class="relay-event-text">${escHtml(relayEventSummaryText(parsed))}</span>`
|
|
119
|
+
+ (timeText ? `<span class="relay-event-time">${escHtml(timeText)}</span>` : '')
|
|
120
|
+
+ '</summary>'
|
|
121
|
+
+ `<ul class="relay-event-lines">${lines}</ul>`
|
|
122
|
+
+ '</details>';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Subscriptions ───────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function activeList(subscriptions) {
|
|
128
|
+
return (Array.isArray(subscriptions) ? subscriptions : []).filter((sub) => sub && !sub.endedAt);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sessionIdsOf(subscription) {
|
|
132
|
+
return Array.isArray(subscription?.sessions) ? subscription.sessions.map((session) => String(session?.id || '')).filter(Boolean) : [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A subscription on a paired relay (this relay's mirror of it): its
|
|
136
|
+
// sessions are that relay's, never this one's conversations.
|
|
137
|
+
function isOnPairedRelay(subscription) {
|
|
138
|
+
return !!subscription?.remoteId || (!!subscription?.relay && subscription.relay !== 'self');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The watcher of a subscription, as the badge names it: a conversation here,
|
|
142
|
+
// or a paired relay's watcher (`remote:<relay id>:<its conversation>`).
|
|
143
|
+
function watcherKey(subscription) {
|
|
144
|
+
const subscriber = subscription?.subscriber;
|
|
145
|
+
return subscriber ? `remote:${subscriber.relayId || ''}:${subscriber.watcherId || ''}` : subscription?.watcher;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What a conversation watches: its subscriptions, how many distinct
|
|
150
|
+
* sessions they list (on this relay and paired ones), and whether one
|
|
151
|
+
* watches all of a relay's sessions.
|
|
152
|
+
*/
|
|
153
|
+
export function watchingSummary(subscriptions, conversationId) {
|
|
154
|
+
const id = String(conversationId || '').trim();
|
|
155
|
+
const mine = activeList(subscriptions).filter((sub) => sub.watcher === id);
|
|
156
|
+
const sessions = new Set(mine.flatMap((sub) => sessionIdsOf(sub).map((sessionId) => `${sub.relay || 'self'}:${sessionId}`)));
|
|
157
|
+
return {
|
|
158
|
+
subscriptions: mine,
|
|
159
|
+
count: sessions.size,
|
|
160
|
+
all: mine.some((sub) => sub.scope === 'all'),
|
|
161
|
+
relays: [...new Set(mine.filter(isOnPairedRelay).map((sub) => sub.relayName || sub.relay))],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The watchers of a conversation of this relay. `includeAll` counts
|
|
167
|
+
* subscriptions to all sessions too; the sidebar leaves them out, or every
|
|
168
|
+
* row would carry it. Subscriptions on paired relays watch none of them.
|
|
169
|
+
*/
|
|
170
|
+
export function watchersOf(subscriptions, conversationId, { includeAll = true } = {}) {
|
|
171
|
+
const id = String(conversationId || '').trim();
|
|
172
|
+
if (!id) return [];
|
|
173
|
+
const watchers = new Set();
|
|
174
|
+
for (const sub of activeList(subscriptions)) {
|
|
175
|
+
if (sub.watcher === id || isOnPairedRelay(sub)) continue;
|
|
176
|
+
if (sub.scope === 'all' ? includeAll : sessionIdsOf(sub).includes(id)) watchers.add(watcherKey(sub));
|
|
177
|
+
}
|
|
178
|
+
return [...watchers];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function watchingChipText(summary) {
|
|
182
|
+
if (!summary?.subscriptions?.length) return '';
|
|
183
|
+
if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
|
|
184
|
+
return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function conversationTitle(conversations, id) {
|
|
188
|
+
const title = String(conversations?.[id]?.title || '').trim();
|
|
189
|
+
return title || `session ${String(id).slice(0, 8)}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function describeSubscription(sub, conversations) {
|
|
193
|
+
const paired = isOnPairedRelay(sub);
|
|
194
|
+
const relay = paired ? String(sub.relayName || sub.relay || 'paired relay') : '';
|
|
195
|
+
const titleOf = (session) => (paired
|
|
196
|
+
? (String(session?.title || '').trim() || `session ${String(session?.id || '').slice(0, 8)}`)
|
|
197
|
+
: conversationTitle(conversations, session.id));
|
|
198
|
+
const listed = (Array.isArray(sub.sessions) ? sub.sessions : []).filter((session) => session?.id).map(titleOf).join(', ');
|
|
199
|
+
const what = sub.scope === 'all'
|
|
200
|
+
? (paired ? `All sessions of ${relay}` : 'All sessions')
|
|
201
|
+
: (paired ? `${relay} · ${listed}` : listed);
|
|
202
|
+
const extras = [
|
|
203
|
+
sub.once ? 'once' : '',
|
|
204
|
+
sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
|
|
205
|
+
].filter(Boolean);
|
|
206
|
+
return { what, events: (sub.events || []).join(', '), extras: extras.join(' · ') };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The chip's list: one row per subscription with its Unsubscribe button. */
|
|
210
|
+
export function renderWatchingPanelHtml(summary, { conversations = null, escHtml = defaultEscape } = {}) {
|
|
211
|
+
const rows = (summary?.subscriptions || []).map((sub) => {
|
|
212
|
+
const { what, events, extras } = describeSubscription(sub, conversations);
|
|
213
|
+
return '<li class="relay-watch-item">'
|
|
214
|
+
+ `<span class="relay-watch-what">${escHtml(what)}</span>`
|
|
215
|
+
+ `<span class="relay-watch-events">${escHtml(events)}${extras ? ` · ${escHtml(extras)}` : ''}</span>`
|
|
216
|
+
+ `<button type="button" class="relay-watch-unsubscribe" data-subscription-id="${escHtml(sub.id)}">Unsubscribe</button>`
|
|
217
|
+
+ '</li>';
|
|
218
|
+
}).join('');
|
|
219
|
+
return `<ul class="relay-watch-list">${rows}</ul>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function watcherName(id, conversations, subscriptions) {
|
|
223
|
+
if (!String(id).startsWith('remote:')) return conversationTitle(conversations, id);
|
|
224
|
+
const sub = activeList(subscriptions).find((entry) => watcherKey(entry) === id);
|
|
225
|
+
const title = String(sub?.subscriber?.watcherTitle || '').trim() || 'a session';
|
|
226
|
+
const relay = String(sub?.subscriber?.name || '').trim() || 'a paired relay';
|
|
227
|
+
return `${title} on ${relay}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function renderWatchedBadgeHtml(watcherIds, { conversations = null, subscriptions = [], escHtml = defaultEscape } = {}) {
|
|
231
|
+
if (!watcherIds?.length) return '';
|
|
232
|
+
const names = watcherIds.map((id) => watcherName(id, conversations, subscriptions)).join(', ');
|
|
233
|
+
return `<span class="conv-watched-badge" title="${escHtml(`Watched by ${names}`)}">watched</span>`;
|
|
234
|
+
}
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
updateTurnCeilingSetting as requestTurnCeilingSetting,
|
|
30
30
|
loadBackgroundTaskTimeoutSetting,
|
|
31
31
|
updateBackgroundTaskTimeoutSetting as requestBackgroundTaskTimeoutSetting,
|
|
32
|
+
loadSubagentRecordRetentionSetting,
|
|
33
|
+
updateSubagentRecordRetentionSetting as requestSubagentRecordRetentionSetting,
|
|
32
34
|
loadFeatureFlagSettings,
|
|
33
35
|
updateFeatureFlagSetting as requestFeatureFlagSetting,
|
|
34
36
|
requestRelayRestart,
|
|
@@ -197,6 +199,54 @@ export async function refreshBackgroundTaskTimeoutSetting() {
|
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
// Mirrors shared/subagent-record-retention.mjs (the browser cannot import it):
|
|
203
|
+
// the select starts at the relay's default until the relay's value loads.
|
|
204
|
+
const DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS = 90;
|
|
205
|
+
let subagentRecordRetentionDays = DEFAULT_SUBAGENT_RECORD_RETENTION_DAYS;
|
|
206
|
+
let subagentRecordRetentionUpdateInFlight = false;
|
|
207
|
+
|
|
208
|
+
function syncSubagentRecordRetentionSelect() {
|
|
209
|
+
const select = document.getElementById('subagent-record-retention-select');
|
|
210
|
+
if (!select || select.tagName !== 'SELECT') return;
|
|
211
|
+
select.value = String(subagentRecordRetentionDays);
|
|
212
|
+
select.disabled = subagentRecordRetentionUpdateInFlight;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function refreshSubagentRecordRetentionSetting() {
|
|
216
|
+
syncSubagentRecordRetentionSelect();
|
|
217
|
+
try {
|
|
218
|
+
const result = await loadSubagentRecordRetentionSetting();
|
|
219
|
+
if (result && Number.isFinite(Number(result.retentionDays))) subagentRecordRetentionDays = Number(result.retentionDays);
|
|
220
|
+
} catch {
|
|
221
|
+
// Keep the last known value; the setting is not critical.
|
|
222
|
+
} finally {
|
|
223
|
+
syncSubagentRecordRetentionSelect();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function updateSubagentRecordRetentionSetting(value) {
|
|
228
|
+
if (subagentRecordRetentionUpdateInFlight) {
|
|
229
|
+
syncSubagentRecordRetentionSelect();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
subagentRecordRetentionUpdateInFlight = true;
|
|
233
|
+
syncSubagentRecordRetentionSelect();
|
|
234
|
+
try {
|
|
235
|
+
const result = await requestSubagentRecordRetentionSetting(Number(value));
|
|
236
|
+
if (result && Number.isFinite(Number(result.retentionDays))) subagentRecordRetentionDays = Number(result.retentionDays);
|
|
237
|
+
showTransientRelayNotice(
|
|
238
|
+
subagentRecordRetentionDays === 0
|
|
239
|
+
? 'Sub-agent tool output is kept for good.'
|
|
240
|
+
: `Sub-agent tool output is removed after ${subagentRecordRetentionDays} days.`,
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
alert(error?.message || 'Failed to update how long sub-agent tool output is kept.');
|
|
244
|
+
} finally {
|
|
245
|
+
subagentRecordRetentionUpdateInFlight = false;
|
|
246
|
+
syncSubagentRecordRetentionSelect();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
200
250
|
export async function updateBackgroundTaskTimeoutSetting(value) {
|
|
201
251
|
if (backgroundTaskTimeoutUpdateInFlight) {
|
|
202
252
|
syncBackgroundTaskTimeoutSlider();
|
|
@@ -1534,7 +1584,7 @@ async function restartRelayForFeatureFlags() {
|
|
|
1534
1584
|
alert('Failed to request a relay restart. Restarts can only be requested from the relay host itself.');
|
|
1535
1585
|
return;
|
|
1536
1586
|
}
|
|
1537
|
-
showTransientRelayNotice('Relay restart requested. It will restart once the
|
|
1587
|
+
showTransientRelayNotice('Relay restart requested. It will restart once the running turns finish; queued messages wait for it.');
|
|
1538
1588
|
} finally {
|
|
1539
1589
|
featureFlagsUpdateInFlight = false;
|
|
1540
1590
|
syncFeatureFlagsSection();
|
|
@@ -1708,6 +1758,8 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1708
1758
|
void refreshTurnCeilingSetting();
|
|
1709
1759
|
syncBackgroundTaskTimeoutSlider();
|
|
1710
1760
|
void refreshBackgroundTaskTimeoutSetting();
|
|
1761
|
+
syncSubagentRecordRetentionSelect();
|
|
1762
|
+
void refreshSubagentRecordRetentionSetting();
|
|
1711
1763
|
void refreshPushSettingsSection();
|
|
1712
1764
|
void refreshPreviewsSection();
|
|
1713
1765
|
void refreshRemoteRelaysSection();
|