@oar-sh/oar 0.9.15 → 0.9.16
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 +85 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +568 -42
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// The Inbox in the page: the sidebar's "Inbox" button with its count, the
|
|
2
|
+
// view it opens in place of a transcript (like Status), and the card a
|
|
3
|
+
// question that does not block shows in its own session. inbox-view.mjs
|
|
4
|
+
// builds the DOM; this module loads, keeps and answers.
|
|
5
|
+
//
|
|
6
|
+
// The list comes from GET /api/inbox (this relay and every paired relay that
|
|
7
|
+
// answers), again on every change of a question here (socket) and every
|
|
8
|
+
// minute for the paired relays. Answering here is answering the card in its
|
|
9
|
+
// session: this relay's through the question routes, a paired relay's ask
|
|
10
|
+
// through this relay (POST /api/inbox/relays/:id/asks/:ask/answer); a paired
|
|
11
|
+
// relay's cards open there.
|
|
12
|
+
|
|
13
|
+
import { apiFetch, answerRelayQuestion } from './api-client.js';
|
|
14
|
+
import { currentConvId, IS_SHARED_VIEW, setCurrentConv, showTransientRelayNotice } from './store.js';
|
|
15
|
+
import { isStatusViewActive, leaveStatusView } from './status-view.mjs';
|
|
16
|
+
import {
|
|
17
|
+
buildAskCard,
|
|
18
|
+
createCoalescedRefresh,
|
|
19
|
+
inboxPendingCount,
|
|
20
|
+
INBOX_FILTERS,
|
|
21
|
+
renderInbox,
|
|
22
|
+
sessionAsksFromInbox,
|
|
23
|
+
} from './inbox-view.mjs';
|
|
24
|
+
|
|
25
|
+
const FILTER_STORAGE_KEY = 'oar_inbox_filter';
|
|
26
|
+
const REFRESH_MS = 60_000;
|
|
27
|
+
const CHANGE_DEBOUNCE_MS = 400;
|
|
28
|
+
|
|
29
|
+
let inboxActive = false;
|
|
30
|
+
let previousConversationId = '';
|
|
31
|
+
let relays = [];
|
|
32
|
+
let loaded = false;
|
|
33
|
+
let filter = 'all';
|
|
34
|
+
let refreshTimer = null;
|
|
35
|
+
let changeTimer = null;
|
|
36
|
+
let clickBound = false;
|
|
37
|
+
let started = false;
|
|
38
|
+
// Typed answers, kept across re-renders: `${relayId}:${itemId}` → text.
|
|
39
|
+
const drafts = new Map();
|
|
40
|
+
// This relay's asks for the session cards: id → ask (each Inbox load
|
|
41
|
+
// replaces it; the socket updates single asks in between).
|
|
42
|
+
let sessionAsks = new Map();
|
|
43
|
+
let sessionCardKey = '';
|
|
44
|
+
|
|
45
|
+
function readFilter() {
|
|
46
|
+
try {
|
|
47
|
+
const stored = String(localStorage.getItem(FILTER_STORAGE_KEY) || '');
|
|
48
|
+
return INBOX_FILTERS.includes(stored) ? stored : 'all';
|
|
49
|
+
} catch {
|
|
50
|
+
return 'all';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isInboxViewActive() {
|
|
55
|
+
return inboxActive;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The sidebar button's count: everything pending, here and on paired relays. */
|
|
59
|
+
function syncInboxBadge() {
|
|
60
|
+
const count = inboxPendingCount(relays);
|
|
61
|
+
const badge = document.getElementById('inbox-count');
|
|
62
|
+
const buttonEl = document.getElementById('inbox-btn');
|
|
63
|
+
if (badge) badge.textContent = count ? String(count) : '';
|
|
64
|
+
if (buttonEl) {
|
|
65
|
+
buttonEl.classList.toggle('has-pending', count > 0);
|
|
66
|
+
buttonEl.setAttribute('aria-label', count ? `Inbox, ${count} question${count === 1 ? '' : 's'} waiting` : 'Inbox');
|
|
67
|
+
buttonEl.setAttribute('aria-pressed', inboxActive ? 'true' : 'false');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function draftKey(node) {
|
|
72
|
+
const card = node?.closest?.('.inbox-item');
|
|
73
|
+
return card ? `${card.dataset.relayId || 'self'}:${card.dataset.itemId || ''}` : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function captureDrafts(host) {
|
|
77
|
+
for (const field of host.querySelectorAll('.inbox-answer-text')) {
|
|
78
|
+
const key = draftKey(field);
|
|
79
|
+
if (key) drafts.set(key, field.value);
|
|
80
|
+
}
|
|
81
|
+
const active = document.activeElement;
|
|
82
|
+
return active?.classList?.contains('inbox-answer-text') && host.contains(active) ? draftKey(active) : '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function restoreDrafts(host, focusedKey) {
|
|
86
|
+
for (const field of host.querySelectorAll('.inbox-answer-text')) {
|
|
87
|
+
const key = draftKey(field);
|
|
88
|
+
if (drafts.has(key)) field.value = drafts.get(key);
|
|
89
|
+
if (key && key === focusedKey) field.focus({ preventScroll: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderInboxNow() {
|
|
94
|
+
if (!inboxActive) return;
|
|
95
|
+
const host = document.getElementById('messages');
|
|
96
|
+
if (!host) return;
|
|
97
|
+
const focused = captureDrafts(host);
|
|
98
|
+
const top = host.scrollTop;
|
|
99
|
+
host.replaceChildren(renderInbox(document, { relays, filter, now: Date.now(), loading: !loaded }));
|
|
100
|
+
restoreDrafts(host, focused);
|
|
101
|
+
host.scrollTop = top;
|
|
102
|
+
const title = document.getElementById('chat-title');
|
|
103
|
+
if (title) {
|
|
104
|
+
const count = inboxPendingCount(relays);
|
|
105
|
+
title.textContent = count ? `Inbox · ${count} waiting` : 'Inbox';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function loadInbox() {
|
|
110
|
+
const response = await apiFetch('/api/inbox');
|
|
111
|
+
if (response && Array.isArray(response.relays)) {
|
|
112
|
+
relays = response.relays;
|
|
113
|
+
loaded = true;
|
|
114
|
+
// Built anew from each load: an ask withdrawn or cancelled meanwhile goes.
|
|
115
|
+
sessionAsks = sessionAsksFromInbox(relays);
|
|
116
|
+
}
|
|
117
|
+
syncInboxBadge();
|
|
118
|
+
renderInboxNow();
|
|
119
|
+
renderSessionAskCards();
|
|
120
|
+
return response;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One load at a time; a change while one runs brings one more after it.
|
|
124
|
+
const refreshCoalesced = createCoalescedRefresh(loadInbox);
|
|
125
|
+
|
|
126
|
+
/** Loads the Inbox: never two at once, and one more when asked while one runs. */
|
|
127
|
+
export function refreshInbox() {
|
|
128
|
+
if (IS_SHARED_VIEW) return Promise.resolve(null);
|
|
129
|
+
return refreshCoalesced();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function scheduleRefresh() {
|
|
133
|
+
if (changeTimer) clearTimeout(changeTimer);
|
|
134
|
+
changeTimer = setTimeout(() => {
|
|
135
|
+
changeTimer = null;
|
|
136
|
+
void refreshInbox();
|
|
137
|
+
}, CHANGE_DEBOUNCE_MS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Socket: an ask of this relay changed (created, answered, dismissed, withdrawn). */
|
|
141
|
+
export function noteRelayAskUpdated(payload = {}) {
|
|
142
|
+
const ask = payload?.ask;
|
|
143
|
+
if (ask?.id) sessionAsks.set(ask.id, ask);
|
|
144
|
+
renderSessionAskCards();
|
|
145
|
+
scheduleRefresh();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Socket: a card a turn waits on came or went. */
|
|
149
|
+
export function noteInboxQuestionsChanged() {
|
|
150
|
+
scheduleRefresh();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function leaveInboxView() {
|
|
154
|
+
const wasActive = inboxActive;
|
|
155
|
+
if (!wasActive) return false;
|
|
156
|
+
inboxActive = false;
|
|
157
|
+
document.body.classList.remove('inbox-view-active');
|
|
158
|
+
document.getElementById('input-area')?.removeAttribute('hidden');
|
|
159
|
+
document.getElementById('pending-question-banner')?.removeAttribute('hidden');
|
|
160
|
+
syncInboxBadge();
|
|
161
|
+
window.renderConvList?.();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function toggleInboxView() {
|
|
166
|
+
if (inboxActive) {
|
|
167
|
+
leaveInboxView();
|
|
168
|
+
const restoreId = previousConversationId;
|
|
169
|
+
previousConversationId = '';
|
|
170
|
+
if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (isStatusViewActive()) leaveStatusView();
|
|
174
|
+
previousConversationId = String(currentConvId || '').trim();
|
|
175
|
+
inboxActive = true;
|
|
176
|
+
setCurrentConv(null);
|
|
177
|
+
window.renderConvList?.();
|
|
178
|
+
if (previousConversationId) localStorage.setItem('copilot_last_conv', previousConversationId);
|
|
179
|
+
document.body.classList.add('inbox-view-active');
|
|
180
|
+
document.getElementById('pending-question-banner')?.setAttribute('hidden', '');
|
|
181
|
+
document.getElementById('input-area')?.setAttribute('hidden', '');
|
|
182
|
+
for (const id of ['chat-title-cwd', 'chat-title-session-usage']) {
|
|
183
|
+
const node = document.getElementById(id);
|
|
184
|
+
if (node) {
|
|
185
|
+
node.textContent = '';
|
|
186
|
+
node.hidden = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
syncInboxBadge();
|
|
190
|
+
renderInboxNow();
|
|
191
|
+
document.getElementById('sidebar')?.classList.remove('open');
|
|
192
|
+
document.getElementById('sidebar-overlay')?.classList.remove('visible');
|
|
193
|
+
await refreshInbox();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ─── Answering ────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
async function postJson(url, body) {
|
|
199
|
+
let refusal = '';
|
|
200
|
+
const response = await apiFetch(url, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
body: JSON.stringify(body || {}),
|
|
203
|
+
onErrorResponse: async (res) => {
|
|
204
|
+
try {
|
|
205
|
+
refusal = String((await res.json())?.error || '');
|
|
206
|
+
} catch {}
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
return { ok: !!response, response, refusal };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function fieldText(card) {
|
|
213
|
+
return String(card?.querySelector('.inbox-answer-text')?.value || '').trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function answerAsk(card, data, { choice = '', dismiss = false } = {}) {
|
|
217
|
+
const relayId = data.relayId || 'self';
|
|
218
|
+
const id = data.itemId;
|
|
219
|
+
const typed = fieldText(card);
|
|
220
|
+
if (!dismiss && !choice && !typed) {
|
|
221
|
+
showTransientRelayNotice('Pick an option or type an answer first.');
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
const body = dismiss ? {} : { choice, answer: typed };
|
|
225
|
+
const url = relayId === 'self'
|
|
226
|
+
? `/api/relay-question/${encodeURIComponent(id)}/${dismiss ? 'dismiss' : 'answer'}`
|
|
227
|
+
: `/api/inbox/relays/${encodeURIComponent(relayId)}/asks/${encodeURIComponent(id)}/${dismiss ? 'dismiss' : 'answer'}`;
|
|
228
|
+
const result = await postJson(url, body);
|
|
229
|
+
if (!result.ok) {
|
|
230
|
+
showTransientRelayNotice(result.refusal || 'The answer did not go through. Try again.');
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
drafts.delete(`${relayId}:${id}`);
|
|
234
|
+
if (relayId === 'self' && result.response?.question?.id) sessionAsks.set(result.response.question.id, result.response.question);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function answerCard(card, data, { choice = '' } = {}) {
|
|
239
|
+
const answer = choice || fieldText(card);
|
|
240
|
+
if (!answer) {
|
|
241
|
+
showTransientRelayNotice('Type an answer first.');
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
let refusal = '';
|
|
245
|
+
const response = await answerRelayQuestion(data.itemId, answer, data.sdkSessionId || null, {
|
|
246
|
+
onErrorResponse: async (res) => {
|
|
247
|
+
try {
|
|
248
|
+
refusal = String((await res.json())?.error || '');
|
|
249
|
+
} catch {}
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
if (!response) {
|
|
253
|
+
showTransientRelayNotice(refusal || 'The answer did not go through. Try again.');
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
drafts.delete(`self:${data.itemId}`);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function handleAction(target) {
|
|
261
|
+
const data = { ...target.dataset };
|
|
262
|
+
const card = target.closest('.inbox-item');
|
|
263
|
+
if (data.inboxFilter) {
|
|
264
|
+
filter = INBOX_FILTERS.includes(data.inboxFilter) ? data.inboxFilter : 'all';
|
|
265
|
+
try { localStorage.setItem(FILTER_STORAGE_KEY, filter); } catch {}
|
|
266
|
+
renderInboxNow();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (data.inboxAction === 'open') {
|
|
270
|
+
const id = String(data.sessionId || '').trim();
|
|
271
|
+
if (!id) return;
|
|
272
|
+
leaveInboxView();
|
|
273
|
+
previousConversationId = '';
|
|
274
|
+
await window.openConversation?.(id);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const buttons = card ? [...card.querySelectorAll('button')] : [target];
|
|
278
|
+
for (const node of buttons) node.disabled = true;
|
|
279
|
+
let done = false;
|
|
280
|
+
try {
|
|
281
|
+
if (data.inboxAction === 'choice') done = await answerAsk(card, data, { choice: data.choice });
|
|
282
|
+
else if (data.inboxAction === 'answer') done = await answerAsk(card, data);
|
|
283
|
+
else if (data.inboxAction === 'dismiss') done = await answerAsk(card, data, { dismiss: true });
|
|
284
|
+
else if (data.inboxAction === 'card-choice') done = await answerCard(card, data, { choice: data.choice });
|
|
285
|
+
else if (data.inboxAction === 'card-answer') done = await answerCard(card, data);
|
|
286
|
+
} finally {
|
|
287
|
+
if (!done) for (const node of buttons) node.disabled = false;
|
|
288
|
+
}
|
|
289
|
+
if (done) {
|
|
290
|
+
renderSessionAskCards();
|
|
291
|
+
await refreshInbox();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function bindClicks() {
|
|
296
|
+
if (clickBound) return;
|
|
297
|
+
clickBound = true;
|
|
298
|
+
const host = document.getElementById('messages');
|
|
299
|
+
host?.addEventListener('click', (event) => {
|
|
300
|
+
const target = event.target instanceof Element ? event.target.closest('[data-inbox-action], [data-inbox-filter]') : null;
|
|
301
|
+
if (!target || !target.closest('.inbox-view, .relay-ask-container')) return;
|
|
302
|
+
event.preventDefault();
|
|
303
|
+
void handleAction(target);
|
|
304
|
+
});
|
|
305
|
+
host?.addEventListener('input', (event) => {
|
|
306
|
+
const field = event.target instanceof Element && event.target.classList.contains('inbox-answer-text') ? event.target : null;
|
|
307
|
+
const key = field ? draftKey(field) : '';
|
|
308
|
+
if (key) drafts.set(key, field.value);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── The card in the session ──────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The pending asks of the conversation on screen, as cards at the end of its
|
|
316
|
+
* transcript (before the cards it waits on). Called with every render of the
|
|
317
|
+
* question cards; a no-op while nothing changed.
|
|
318
|
+
*/
|
|
319
|
+
export function renderSessionAskCards() {
|
|
320
|
+
const host = document.getElementById('messages');
|
|
321
|
+
if (!host || IS_SHARED_VIEW) return;
|
|
322
|
+
const id = String(currentConvId || '').trim();
|
|
323
|
+
const shown = inboxActive || isStatusViewActive() || !id
|
|
324
|
+
? []
|
|
325
|
+
: [...sessionAsks.values()]
|
|
326
|
+
.filter((ask) => ask && (ask.conversationId || ask.session?.id) === id && ask.status === 'pending')
|
|
327
|
+
.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
|
|
328
|
+
const key = JSON.stringify([id, shown.map((ask) => [ask.id, ask.status])]);
|
|
329
|
+
const existing = host.querySelectorAll('.relay-ask-container');
|
|
330
|
+
if (key === sessionCardKey && existing.length === shown.length && [...existing].every((node) => node.isConnected)) return;
|
|
331
|
+
sessionCardKey = key;
|
|
332
|
+
const focused = captureDrafts(host);
|
|
333
|
+
existing.forEach((node) => node.remove());
|
|
334
|
+
const firstQuestion = host.querySelector('.relay-question-container');
|
|
335
|
+
for (const ask of shown) {
|
|
336
|
+
const wrapper = document.createElement('div');
|
|
337
|
+
wrapper.className = 'msg relay-ask-container';
|
|
338
|
+
wrapper.dataset.askId = ask.id;
|
|
339
|
+
wrapper.append(buildAskCard(document, { ...ask, session: ask.session || { id } }, { relay: { id: 'self', self: true }, mode: 'session', now: Date.now() }));
|
|
340
|
+
if (firstQuestion) host.insertBefore(wrapper, firstQuestion);
|
|
341
|
+
else host.append(wrapper);
|
|
342
|
+
}
|
|
343
|
+
restoreDrafts(host, focused);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Boot: the count, the session cards, a refresh every minute (paired relays). */
|
|
347
|
+
export function startInbox() {
|
|
348
|
+
if (started || IS_SHARED_VIEW) return;
|
|
349
|
+
started = true;
|
|
350
|
+
filter = readFilter();
|
|
351
|
+
bindClicks();
|
|
352
|
+
syncInboxBadge();
|
|
353
|
+
void refreshInbox();
|
|
354
|
+
refreshTimer = setInterval(() => { void refreshInbox(); }, REFRESH_MS);
|
|
355
|
+
refreshTimer?.unref?.();
|
|
356
|
+
}
|