@oar-sh/oar 0.9.14 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +525 -12
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  6. package/server/claude-worker/claude-session-process.mjs +4 -4
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  8. package/server/db-schema.mjs +52 -0
  9. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  10. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  11. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  12. package/server/migrations/0022-relay-schedules.mjs +142 -0
  13. package/server/migrations/0023-relay-asks.mjs +141 -0
  14. package/server/public/app/ask-user-view.js +3 -0
  15. package/server/public/app/bootstrap.js +61 -3
  16. package/server/public/app/context-usage-view.mjs +17 -0
  17. package/server/public/app/conversation-group-order.mjs +35 -0
  18. package/server/public/app/inbox-ui.js +356 -0
  19. package/server/public/app/inbox-view.mjs +370 -0
  20. package/server/public/app/journal-view.js +23 -2
  21. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  22. package/server/public/app/relay-event-view.mjs +33 -7
  23. package/server/public/app/relay-schedule-view.mjs +119 -0
  24. package/server/public/app/relay-schedules-ui.js +172 -0
  25. package/server/public/app/settings-modal.js +17 -3
  26. package/server/public/app/socket-handlers.js +27 -3
  27. package/server/public/app/store.js +18 -5
  28. package/server/public/index.html +159 -2
  29. package/server/relay-tools.md +18 -3
  30. package/server/repositories/conversation-group-repository.mjs +97 -11
  31. package/server/repositories/remote-relay-repository.mjs +20 -2
  32. package/server/repositories/session-repository.mjs +7 -1
  33. package/server/routes/ask-user-routes.mjs +57 -3
  34. package/server/routes/cache-routes.mjs +6 -0
  35. package/server/routes/conversation-group-routes.mjs +17 -2
  36. package/server/routes/messages-routes.mjs +3 -3
  37. package/server/routes/relay-ask-routes.mjs +387 -0
  38. package/server/routes/relay-event-routes.mjs +3 -1
  39. package/server/routes/relay-schedule-routes.mjs +57 -0
  40. package/server/routes/sessions-routes.mjs +613 -56
  41. package/server/server-runtime.mjs +80 -1
  42. package/server/services/cache-rebuild-service.mjs +19 -2
  43. package/server/services/conversation-preferences-service.mjs +17 -0
  44. package/server/services/oar-cli-helpers.mjs +12 -4
  45. package/server/services/relay-ask-link-service.mjs +174 -0
  46. package/server/services/relay-ask-service.mjs +604 -0
  47. package/server/services/relay-event-service.mjs +589 -73
  48. package/server/services/relay-schedule-service.mjs +596 -0
  49. package/server/services/remote-relay-caller-context.mjs +89 -20
  50. package/server/services/remote-relay-dispatcher.mjs +956 -30
  51. package/server/services/windows-autostart-service.mjs +88 -16
  52. package/server/services/windows-boot-autostart-service.mjs +775 -126
  53. package/shared/relay-asks.mjs +145 -0
  54. package/shared/relay-events.mjs +268 -18
  55. package/shared/relay-schedules.mjs +128 -0
  56. package/shared/remote-relay-contract.mjs +553 -25
  57. package/shared/remote-relay-tool-core.mjs +16 -5
  58. package/.github/extensions/web-relay/README.md +0 -190
  59. package/server/README.md +0 -2334
@@ -0,0 +1,370 @@
1
+ // The Inbox, as DOM: every pending question across this relay and its paired
2
+ // relays (GET /api/inbox), grouped by relay, newest first; what was answered
3
+ // or dismissed in the last day under a divider. And the card a question that
4
+ // does not block (remote_relay `ask`) shows in its own session.
5
+ //
6
+ // Every text here is written by an agent, some on other relays: it goes into
7
+ // the page through textContent only, never as HTML or a link. Buttons carry
8
+ // what they act on in data attributes; inbox-ui.js handles the clicks.
9
+ //
10
+ // The browser cannot import shared/ (only server/public is served): the few
11
+ // values of shared/relay-asks.mjs this needs are kept here, and
12
+ // inbox-view.test.mjs asserts they match.
13
+
14
+ export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
15
+ export const INBOX_ANSWERED_KEEP_MS = 24 * 60 * 60_000;
16
+ export const INBOX_FILTERS = Object.freeze(['all', 'mine', 'agents']);
17
+
18
+ const CLOSED_STATUSES = new Set(['answered', 'dismissed']);
19
+
20
+ function toText(value) {
21
+ return String(value ?? '').trim();
22
+ }
23
+
24
+ function el(doc, tag, className = '', text = null) {
25
+ const node = doc.createElement(tag);
26
+ if (className) node.className = className;
27
+ if (text !== null && text !== undefined) node.textContent = String(text);
28
+ return node;
29
+ }
30
+
31
+ function button(doc, className, label, data = {}) {
32
+ const node = el(doc, 'button', className, label);
33
+ node.type = 'button';
34
+ for (const [key, value] of Object.entries(data)) {
35
+ if (value !== undefined && value !== null && value !== '') node.dataset[key] = String(value);
36
+ }
37
+ return node;
38
+ }
39
+
40
+ /** "just now", "5 min", "3 h", "2 d" since `createdAt`. */
41
+ export function formatInboxAge(createdAt, now = Date.now()) {
42
+ const at = Date.parse(toText(createdAt));
43
+ if (!Number.isFinite(at)) return '';
44
+ const minutes = Math.max(0, Math.floor((now - at) / 60_000));
45
+ if (minutes < 1) return 'just now';
46
+ if (minutes < 60) return `${minutes} min`;
47
+ const hours = Math.floor(minutes / 60);
48
+ if (hours < 48) return `${hours} h`;
49
+ return `${Math.floor(hours / 24)} d`;
50
+ }
51
+
52
+ /** A link that opens one session in a relay's web UI (remoteConversationUrl's copy). */
53
+ export function inboxSessionUrl(relayUrl, sessionId) {
54
+ const base = toText(relayUrl).replace(/\/+$/, '');
55
+ const id = toText(sessionId);
56
+ if (!base || !/^https?:\/\//i.test(base)) return '';
57
+ return id ? `${base}/?conv=${encodeURIComponent(id)}` : `${base}/`;
58
+ }
59
+
60
+ /**
61
+ * The filter: "mine" are the questions in sessions the owner started,
62
+ * "agents" those in sessions an agent started.
63
+ */
64
+ export function inboxFilterMatches(item, filter = 'all') {
65
+ if (filter === 'mine') return item?.session?.startedBy !== 'agent';
66
+ if (filter === 'agents') return item?.session?.startedBy === 'agent';
67
+ return true;
68
+ }
69
+
70
+ function isClosed(item) {
71
+ return CLOSED_STATUSES.has(toText(item?.status));
72
+ }
73
+
74
+ function newestFirst(a, b) {
75
+ return toText(b?.createdAt).localeCompare(toText(a?.createdAt));
76
+ }
77
+
78
+ /**
79
+ * The Inbox's groups: per relay (this one first, then the paired ones as
80
+ * listed), its pending items newest first and its items closed in the last
81
+ * day; a relay that did not answer shows as such, with nothing listed.
82
+ */
83
+ export function inboxSections(relays, { filter = 'all', now = Date.now() } = {}) {
84
+ return (Array.isArray(relays) ? relays : []).map((relay) => {
85
+ const items = (Array.isArray(relay?.items) ? relay.items : []).filter((item) => item && toText(item.id) && inboxFilterMatches(item, filter));
86
+ const pending = items.filter((item) => toText(item.status) === 'pending').sort(newestFirst);
87
+ const closed = items
88
+ .filter((item) => isClosed(item) && now - (Date.parse(toText(item.answeredAt || item.createdAt)) || 0) <= INBOX_ANSWERED_KEEP_MS)
89
+ .sort((a, b) => toText(b.answeredAt || b.createdAt).localeCompare(toText(a.answeredAt || a.createdAt)));
90
+ return {
91
+ relay: {
92
+ id: toText(relay?.id),
93
+ name: toText(relay?.name) || toText(relay?.id),
94
+ self: relay?.self === true,
95
+ url: toText(relay?.url),
96
+ reachable: relay?.reachable !== false,
97
+ error: toText(relay?.error),
98
+ },
99
+ pending,
100
+ closed,
101
+ };
102
+ });
103
+ }
104
+
105
+ /**
106
+ * A load that never runs twice at once, and never misses a change: a call
107
+ * while one runs is answered by that one and asks for one more after it
108
+ * (once, however many calls came meanwhile). Resolves with the last load's
109
+ * result.
110
+ */
111
+ export function createCoalescedRefresh(load) {
112
+ let running = null;
113
+ let again = false;
114
+ function refresh() {
115
+ if (running) {
116
+ again = true;
117
+ return running;
118
+ }
119
+ running = (async () => {
120
+ let result;
121
+ try {
122
+ do {
123
+ again = false;
124
+ result = await load();
125
+ } while (again);
126
+ return result;
127
+ } finally {
128
+ running = null;
129
+ }
130
+ })();
131
+ return running;
132
+ }
133
+ return refresh;
134
+ }
135
+
136
+ /**
137
+ * This relay's asks out of an Inbox load (GET /api/inbox), by id: what the
138
+ * session cards show. Built anew from each load, so an ask that was
139
+ * withdrawn or cancelled (no longer listed) drops out.
140
+ */
141
+ export function sessionAsksFromInbox(relays) {
142
+ const own = (Array.isArray(relays) ? relays : []).find((relay) => relay?.self === true);
143
+ const asks = new Map();
144
+ for (const item of Array.isArray(own?.items) ? own.items : []) {
145
+ if (item?.kind === 'ask' && toText(item.id)) asks.set(item.id, { ...item, conversationId: item.session?.id });
146
+ }
147
+ return asks;
148
+ }
149
+
150
+ /** Pending items across every relay (the sidebar's badge), whatever the filter. */
151
+ export function inboxPendingCount(relays) {
152
+ return (Array.isArray(relays) ? relays : [])
153
+ .reduce((sum, relay) => sum + (Array.isArray(relay?.items) ? relay.items : []).filter((item) => toText(item?.status) === 'pending').length, 0);
154
+ }
155
+
156
+ function headLine(doc, item, { relay, now, label }) {
157
+ const head = el(doc, 'div', 'inbox-item-head');
158
+ head.append(el(doc, 'span', 'inbox-item-kind', label));
159
+ if (item.urgency === 'now') head.append(el(doc, 'span', 'inbox-item-urgent', 'urgent'));
160
+ const age = formatInboxAge(item.createdAt, now);
161
+ if (age) head.append(el(doc, 'span', 'inbox-item-age', age));
162
+ if (relay && !relay.self) head.append(el(doc, 'span', 'inbox-item-relay', relay.name));
163
+ return head;
164
+ }
165
+
166
+ function sessionLine(doc, item, relay, { withLink = true } = {}) {
167
+ const line = el(doc, 'div', 'inbox-item-session');
168
+ const title = toText(item?.session?.title) || 'Untitled session';
169
+ if (withLink && relay?.self) {
170
+ line.append(button(doc, 'inbox-open-session', title, { inboxAction: 'open', sessionId: item?.session?.id }));
171
+ } else if (withLink && relay && !relay.self) {
172
+ const url = inboxSessionUrl(relay.url, item?.session?.id);
173
+ if (url) {
174
+ const link = el(doc, 'a', 'inbox-open-there', title);
175
+ link.href = url;
176
+ link.target = '_blank';
177
+ link.rel = 'noopener noreferrer';
178
+ line.append(link);
179
+ } else {
180
+ line.append(el(doc, 'span', 'inbox-session-title', title));
181
+ }
182
+ } else {
183
+ line.append(el(doc, 'span', 'inbox-session-title', title));
184
+ }
185
+ const asker = item?.askedBy;
186
+ if (item?.onBehalf && asker) {
187
+ const who = [toText(asker.title) || toText(asker.conversationId), toText(asker.relay) ? `on ${toText(asker.relay)}` : ''].filter(Boolean).join(' ');
188
+ if (who) line.append(el(doc, 'span', 'inbox-item-asker', `asked by ${who}`));
189
+ }
190
+ return line;
191
+ }
192
+
193
+ function outcomeLine(doc, item) {
194
+ if (item.status === 'dismissed') return el(doc, 'div', 'inbox-item-outcome', 'Dismissed');
195
+ if (item.status !== 'answered') return null;
196
+ const answer = toText(item.answer) || toText(item.choice);
197
+ const via = toText(item.answeredVia);
198
+ return el(doc, 'div', 'inbox-item-outcome', `Answered: ${answer}${via ? ` (from ${via})` : ''}`);
199
+ }
200
+
201
+ /**
202
+ * A question that does not block, as a card: in the Inbox (`mode` "inbox",
203
+ * with its session and relay) or in its session ("session"). `answerable`:
204
+ * the buttons answer it (this relay's, or a paired relay's ask).
205
+ */
206
+ export function buildAskCard(doc, item, { relay = null, mode = 'inbox', now = Date.now(), answerable = true } = {}) {
207
+ const card = el(doc, 'article', `inbox-item inbox-ask${mode === 'session' ? ' relay-ask-card' : ''}`);
208
+ card.dataset.itemId = toText(item.id);
209
+ card.dataset.kind = 'ask';
210
+ if (relay?.id) card.dataset.relayId = relay.id;
211
+ card.dataset.status = toText(item.status) || 'pending';
212
+ card.append(headLine(doc, item, { relay, now, label: mode === 'session' ? 'Question for you · does not block this session' : 'Question' }));
213
+ if (mode === 'inbox') card.append(sessionLine(doc, item, relay));
214
+ else if (item.onBehalf && item.askedBy) card.append(sessionLine(doc, item, relay, { withLink: false }));
215
+ card.append(el(doc, 'p', 'inbox-item-question', item.question || item.prompt || ''));
216
+ const options = Array.isArray(item.options) ? item.options.map(toText).filter(Boolean) : [];
217
+ const recommendation = toText(item.recommendation);
218
+ const recommendedOption = options.find((option) => option === recommendation) || '';
219
+ if (recommendation && !recommendedOption) card.append(el(doc, 'p', 'inbox-item-recommendation', `Recommended: ${recommendation}`));
220
+ if (toText(item.detail)) {
221
+ const details = el(doc, 'details', 'inbox-item-detail');
222
+ details.append(el(doc, 'summary', '', 'Details'));
223
+ // Plain text, line breaks kept: never parsed as markup.
224
+ details.append(el(doc, 'pre', 'inbox-item-detail-text', item.detail));
225
+ card.append(details);
226
+ }
227
+ const outcome = outcomeLine(doc, item);
228
+ if (outcome) card.append(outcome);
229
+ if (item.status === 'pending' && answerable) {
230
+ const data = { relayId: relay?.id || 'self', itemId: item.id };
231
+ if (options.length) {
232
+ const row = el(doc, 'div', 'inbox-item-options');
233
+ for (const option of options) {
234
+ const choice = button(doc, `inbox-choice${option === recommendedOption ? ' recommended' : ''}`, option, { ...data, inboxAction: 'choice', choice: option });
235
+ if (option === recommendedOption) {
236
+ choice.append(el(doc, 'span', 'inbox-recommended-tag', 'recommended'));
237
+ choice.setAttribute('aria-label', `${option} (recommended)`);
238
+ }
239
+ row.append(choice);
240
+ }
241
+ card.append(row);
242
+ }
243
+ const reply = el(doc, 'div', 'inbox-item-reply');
244
+ const field = el(doc, 'textarea', 'inbox-answer-text');
245
+ field.rows = 2;
246
+ field.placeholder = options.length ? 'Or type an answer…' : 'Your answer…';
247
+ field.setAttribute('aria-label', 'Answer');
248
+ field.dataset.itemId = toText(item.id);
249
+ reply.append(field);
250
+ const actions = el(doc, 'div', 'inbox-item-actions');
251
+ actions.append(button(doc, 'inbox-answer', 'Answer', { ...data, inboxAction: 'answer' }));
252
+ actions.append(button(doc, 'inbox-dismiss', 'Dismiss', { ...data, inboxAction: 'dismiss' }));
253
+ reply.append(actions);
254
+ card.append(reply);
255
+ }
256
+ return card;
257
+ }
258
+
259
+ /**
260
+ * A card a turn waits on (ask_user) or an approval card, in the Inbox. Here:
261
+ * a plain one is answered like in its session (choices, or typed text); one
262
+ * with fields or several picks opens its session. A paired relay's is never
263
+ * answered from here: "Open there".
264
+ */
265
+ export function buildCardItem(doc, item, { relay = null, now = Date.now() } = {}) {
266
+ const approval = item.kind === 'approval';
267
+ const card = el(doc, 'article', `inbox-item inbox-card${approval ? ' inbox-approval' : ''}`);
268
+ card.dataset.itemId = toText(item.id);
269
+ card.dataset.kind = toText(item.kind) || 'ask_user';
270
+ if (relay?.id) card.dataset.relayId = relay.id;
271
+ card.dataset.status = toText(item.status) || 'pending';
272
+ card.append(headLine(doc, item, { relay, now, label: approval ? 'Approval · the session waits' : 'Question · the session waits' }));
273
+ card.append(sessionLine(doc, item, relay));
274
+ if (toText(item.header)) card.append(el(doc, 'div', 'inbox-item-header', item.header));
275
+ if (toText(item.question)) card.append(el(doc, 'p', 'inbox-item-question', item.question));
276
+ else if (approval) card.append(el(doc, 'p', 'inbox-item-question', 'An Allow / Deny card on that relay.'));
277
+ const outcome = outcomeLine(doc, item);
278
+ if (outcome) card.append(outcome);
279
+ if (item.status !== 'pending') return card;
280
+ if (!relay?.self) {
281
+ const url = inboxSessionUrl(relay?.url, item?.session?.id);
282
+ const actions = el(doc, 'div', 'inbox-item-actions');
283
+ if (url) {
284
+ const link = el(doc, 'a', 'inbox-open-there inbox-open-there-button', 'Open there');
285
+ link.href = url;
286
+ link.target = '_blank';
287
+ link.rel = 'noopener noreferrer';
288
+ actions.append(link);
289
+ } else {
290
+ actions.append(el(doc, 'span', 'inbox-item-note', 'Answer it on that relay.'));
291
+ }
292
+ card.append(actions);
293
+ return card;
294
+ }
295
+ const options = Array.isArray(item.options) ? item.options.map(toText).filter(Boolean) : [];
296
+ const data = { relayId: 'self', itemId: item.id, sessionId: item?.session?.id, sdkSessionId: item.sdkSessionId || '' };
297
+ if (item.structured || item.multiSelect) {
298
+ const actions = el(doc, 'div', 'inbox-item-actions');
299
+ actions.append(button(doc, 'inbox-open-session-button', 'Open the session to answer', { inboxAction: 'open', sessionId: item?.session?.id }));
300
+ card.append(actions);
301
+ return card;
302
+ }
303
+ if (options.length) {
304
+ const row = el(doc, 'div', 'inbox-item-options');
305
+ for (const option of options) row.append(button(doc, 'inbox-choice', option, { ...data, inboxAction: 'card-choice', choice: option }));
306
+ card.append(row);
307
+ }
308
+ if (item.allowFreeform !== false || !options.length) {
309
+ const reply = el(doc, 'div', 'inbox-item-reply');
310
+ const field = el(doc, 'textarea', 'inbox-answer-text');
311
+ field.rows = 2;
312
+ field.placeholder = 'Your answer…';
313
+ field.setAttribute('aria-label', 'Answer');
314
+ field.dataset.itemId = toText(item.id);
315
+ reply.append(field);
316
+ const actions = el(doc, 'div', 'inbox-item-actions');
317
+ actions.append(button(doc, 'inbox-answer', 'Answer', { ...data, inboxAction: 'card-answer' }));
318
+ reply.append(actions);
319
+ card.append(reply);
320
+ }
321
+ return card;
322
+ }
323
+
324
+ function buildItem(doc, item, relay, now) {
325
+ if (item.kind === 'ask') return buildAskCard(doc, item, { relay, now, mode: 'inbox', answerable: true });
326
+ return buildCardItem(doc, item, { relay, now });
327
+ }
328
+
329
+ /** The whole Inbox: the filter, then one group per relay. */
330
+ export function renderInbox(doc, { relays = [], filter = 'all', now = Date.now(), loading = false } = {}) {
331
+ const root = el(doc, 'section', 'status-view inbox-view');
332
+ root.setAttribute('aria-label', 'Inbox');
333
+ const bar = el(doc, 'div', 'inbox-filter');
334
+ bar.setAttribute('role', 'group');
335
+ bar.setAttribute('aria-label', 'Show');
336
+ for (const [value, label] of [['all', 'All'], ['mine', 'Mine'], ['agents', 'Agents']]) {
337
+ const option = button(doc, `inbox-filter-option${filter === value ? ' active' : ''}`, label, { inboxFilter: value });
338
+ option.setAttribute('aria-pressed', filter === value ? 'true' : 'false');
339
+ bar.append(option);
340
+ }
341
+ root.append(bar);
342
+ const sections = inboxSections(relays, { filter, now });
343
+ let anything = false;
344
+ for (const section of sections) {
345
+ const group = el(doc, 'section', 'inbox-relay');
346
+ group.dataset.relayId = section.relay.id;
347
+ const heading = el(doc, 'h2', 'inbox-relay-name', section.relay.self ? `${section.relay.name} (this relay)` : section.relay.name);
348
+ group.append(heading);
349
+ if (!section.relay.reachable) {
350
+ group.append(el(doc, 'p', 'inbox-relay-unreachable', `Not reachable${section.relay.error ? `: ${section.relay.error}` : ''}`));
351
+ root.append(group);
352
+ continue;
353
+ }
354
+ if (!section.pending.length && !section.closed.length) {
355
+ if (!section.relay.self) continue;
356
+ group.append(el(doc, 'p', 'inbox-empty', loading ? 'Loading…' : 'Nothing waits for you here.'));
357
+ }
358
+ for (const item of section.pending) group.append(buildItem(doc, item, section.relay, now));
359
+ if (section.closed.length) {
360
+ const divider = el(doc, 'div', 'inbox-divider', 'Answered in the last day');
361
+ divider.setAttribute('role', 'separator');
362
+ group.append(divider);
363
+ for (const item of section.closed) group.append(buildItem(doc, item, section.relay, now));
364
+ }
365
+ anything = anything || section.pending.length > 0 || section.closed.length > 0;
366
+ root.append(group);
367
+ }
368
+ if (!anything && filter !== 'all') root.append(el(doc, 'p', 'inbox-empty', 'Nothing matches this filter.'));
369
+ return root;
370
+ }
@@ -29,6 +29,7 @@ import {
29
29
  setSummaryModalLeaveHandler,
30
30
  conversationGroups,
31
31
  mergeConversationGroups,
32
+ forgetKeptEmptyConversationGroups,
32
33
  readConversationGroupFoldChoices,
33
34
  pruneConversationGroups,
34
35
  hasSessionWorkerStatus,
@@ -113,6 +114,7 @@ import {
113
114
  conversationProviderIndicatorLabel,
114
115
  } from './conversation-provider-indicator.mjs';
115
116
  import { leaveStatusView } from './status-view.mjs';
117
+ import { leaveInboxView } from './inbox-ui.js';
116
118
  import {
117
119
  CLAUDE_CLOUD_PROVIDER,
118
120
  buildCloudSourceWarnings,
@@ -132,6 +134,7 @@ import {
132
134
  } from './claude-cloud-ui.mjs';
133
135
  import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
134
136
  import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
137
+ import { relayScheduleMarkerHtml } from './relay-schedules-ui.js';
135
138
  import {
136
139
  normalizeConversationFilter,
137
140
  conversationMatchesFilter,
@@ -378,6 +381,9 @@ export async function refreshConversations(options = {}) {
378
381
  if (!knownConversationIds.has(id)) delete conversations[id];
379
382
  }
380
383
  }
384
+ // The owner's kept empty groups come with the first page only: the page
385
+ // names the ones still kept.
386
+ forgetKeptEmptyConversationGroups();
381
387
  mergeConversationGroups(r.groups);
382
388
  applyConversationPage(r.conversations || []);
383
389
  conversationRowsFetchedSinceRefresh = Array.isArray(r.conversations) ? r.conversations.length : 0;
@@ -613,6 +619,8 @@ function paintConvList() {
613
619
  const items = arrangeConversationList(inView, conversationGroups, {
614
620
  matches: activeFilter ? (row) => conversationMatchesFilter(row, activeFilter) : null,
615
621
  groupNameMatches: activeFilter ? (info) => conversationMatchesFilter({ title: info?.displayName || info?.name }, activeFilter) : null,
622
+ // An owner's group an agent emptied shows in the live list, empty.
623
+ emptyGroups: !conversationListArchivedView,
616
624
  isOpen: (groupId, group) => {
617
625
  const described = describeGroupForHead(group, viewOf);
618
626
  const holdsOpenConversation = !!currentConvId && group.members.some((row) => row.id === currentConvId);
@@ -690,6 +698,8 @@ function paintConvList() {
690
698
  : renderConversationOriginMarkerHtml(c, { conversations });
691
699
  // "watched": a session another conversation watches by id (relay events).
692
700
  const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
701
+ // "scheduled": a schedule fires into this session (remote_relay schedule).
702
+ const scheduledMarkerHtml = relayScheduleMarkerHtml(c.id);
693
703
  const groupClass = item.kind === 'head' ? ' conv-group-head' : (item.kind === 'member' ? ' conv-group-member' : '');
694
704
  const groupAttr = item.kind === 'head' || item.kind === 'member' ? ` data-group-id="${escHtml(item.group.id)}"` : '';
695
705
  // A head row shows the name the group was given by hand; until then the
@@ -700,7 +710,7 @@ function paintConvList() {
700
710
  return `
701
711
  <div class="conv-item worker-ui-${view.visualState}${view.working ? ' conv-working' : ''}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}${groupClass}" data-conversation-id="${c.id}"${groupAttr} onclick="openConversation('${c.id}')">
702
712
  <div class="conv-title"${titleAttr}>${item.kind === 'head' ? foldButtonHtml(item) : ''}${escHtml(rowTitle)}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
703
- <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${providerIndicatorHtml}</div>
713
+ <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${item.kind === 'head' ? groupPillHtml(item) : ''}${originMarkerHtml}${watchedMarkerHtml}${scheduledMarkerHtml}${providerIndicatorHtml}</div>
704
714
  ${c.archived
705
715
  ? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
706
716
  : `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
@@ -711,6 +721,15 @@ function paintConvList() {
711
721
  // archived or deleted): a head line of its own, with the name and the pill.
712
722
  const syntheticHeadHtml = (item) => {
713
723
  const name = groupDisplayName(item.group.id);
724
+ // An owner's group an agent emptied: no members, nothing to fold; its
725
+ // menu (right-click or long-press) renames or dissolves it.
726
+ if (item.group.empty) {
727
+ return `
728
+ <div class="conv-item conv-group-head conv-group-synthetic conv-group-empty worker-ui-offline" data-group-id="${escHtml(item.group.id)}" title="An agent moved the last session out of this group; rename or dissolve it from its menu.">
729
+ <div class="conv-title">${escHtml(name)}</div>
730
+ <div class="conv-meta"><span class="conv-meta-primary">group · empty</span></div>
731
+ </div>`;
732
+ }
714
733
  const described = groupDescriptions.get(item.group.id) || describeGroupForHead(item.group, viewOf);
715
734
  return `
716
735
  <div class="conv-item conv-group-head conv-group-synthetic worker-ui-${described.summary.strongest === 'waiting' ? 'question' : 'offline'}" data-group-id="${escHtml(item.group.id)}" onclick="toggleConversationGroupFold(event,'${escHtml(item.group.id)}')">
@@ -1059,7 +1078,9 @@ function applyFreshOverCachedConversation(id, response, { cached, keepTranscript
1059
1078
  }
1060
1079
 
1061
1080
  export async function openConversation(id, options = {}) {
1062
- const didLeaveStatusView = leaveStatusView();
1081
+ // The Inbox owns the transcript like the status view does.
1082
+ const didLeaveInboxView = leaveInboxView();
1083
+ const didLeaveStatusView = leaveStatusView() || didLeaveInboxView;
1063
1084
  document.getElementById('input-area')?.removeAttribute('hidden');
1064
1085
  const previousConversationId = String(currentConvId || '').trim();
1065
1086
  const nextConversationId = String(id || '').trim();
@@ -10,6 +10,7 @@ import {
10
10
  renderWatchedBadgeHtml,
11
11
  renderWatchingPanelHtml,
12
12
  watchersOf,
13
+ watchingChipHint,
13
14
  watchingChipText,
14
15
  watchingSummary,
15
16
  } from './relay-event-view.mjs';
@@ -164,15 +165,16 @@ export function syncRelayEventHeader() {
164
165
  }
165
166
  const summary = watchingSummary(subscriptions, id);
166
167
  const chipText = watchingChipText(summary);
168
+ const chipHint = chipText ? watchingChipHint(summary) : '';
167
169
  const watchers = watchersOf(subscriptions, id, { includeAll: true });
168
170
  if (!chipText) panelOpen = false;
169
- const key = JSON.stringify([id, chipText, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
171
+ const key = JSON.stringify([id, chipText, chipHint, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
170
172
  if (key === renderedChipKey) return;
171
173
  renderedChipKey = key;
172
174
  const parts = [];
173
175
  if (chipText) {
174
176
  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>`);
177
+ 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)}${chipHint ? ` <span class="relay-watch-hint">${escHtml(chipHint)}</span>` : ''}</button>`);
176
178
  }
177
179
  const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
178
180
  if (badge) parts.push(badge);
@@ -28,6 +28,7 @@ const EVENT_TYPES = new Set([
28
28
  ]);
29
29
  const URGENT_TYPES = new Set(['question_asked', 'error']);
30
30
  const DIGEST_PATTERN = /^Digest of \d+ events?/;
31
+ const QUIET_PATTERN = /^Held during quiet hours/;
31
32
  const REPLAYED_PATTERN = /^Replayed: \d+ events?/;
32
33
  const LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
33
34
  const LINE_PREFIX = '[relay event] ';
@@ -63,15 +64,17 @@ export function isRelayEventMessage(message) {
63
64
  /**
64
65
  * The event lines of an event message without the lead, the types they
65
66
  * carry in order, how many events (a digest's "… and N more" counted),
66
- * whether it is a digest, and whether it replays what a paired relay kept
67
- * while the link to it was down. Any text renders: a stray one as one line.
67
+ * whether it is a digest, whether quiet hours held it, and whether it
68
+ * replays what a paired relay kept while the link to it was down. Any text
69
+ * renders: a stray one as one line.
68
70
  */
69
71
  export function parseRelayEventMessageText(text) {
70
72
  const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
71
73
  const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
72
- const digest = DIGEST_PATTERN.test(body[0] || '');
74
+ const digest = body.some((line) => DIGEST_PATTERN.test(line));
75
+ const quiet = body.some((line) => QUIET_PATTERN.test(line));
73
76
  const replayed = body.some((line) => REPLAYED_PATTERN.test(line));
74
- const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
77
+ const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !QUIET_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
75
78
  const types = [];
76
79
  let count = 0;
77
80
  for (const line of lines) {
@@ -84,7 +87,7 @@ export function parseRelayEventMessageText(text) {
84
87
  const more = /^\[relay event\] … and (\d+) more$/.exec(line);
85
88
  if (more) count += Number(more[1]) || 0;
86
89
  }
87
- return { lines, types, count: count || lines.length, digest, replayed };
90
+ return { lines, types, count: count || lines.length, digest, quiet, replayed };
88
91
  }
89
92
 
90
93
  /** The collapsed row's one line: "3 events · question, reply". */
@@ -92,7 +95,8 @@ export function relayEventSummaryText(parsed) {
92
95
  const count = Number(parsed?.count) || 0;
93
96
  const labels = (parsed?.types || []).map((type) => TYPE_LABELS[type] || type);
94
97
  const events = `${count} event${count === 1 ? '' : 's'}`;
95
- const head = parsed?.digest ? `Digest · ${events}` : events;
98
+ const tags = [parsed?.quiet ? 'After quiet hours' : '', parsed?.digest ? 'Digest' : ''].filter(Boolean);
99
+ const head = [...tags, events].join(' · ');
96
100
  return labels.length ? `${head} · ${labels.join(', ')}` : head;
97
101
  }
98
102
 
@@ -175,11 +179,23 @@ export function watchersOf(subscriptions, conversationId, { includeAll = true }
175
179
  const watchers = new Set();
176
180
  for (const sub of activeList(subscriptions)) {
177
181
  if (sub.watcher === id || isOnPairedRelay(sub)) continue;
178
- if (sub.scope === 'all' ? includeAll : sessionIdsOf(sub).includes(id)) watchers.add(watcherKey(sub));
182
+ const watches = sub.scope === 'all'
183
+ ? includeAll && !(Array.isArray(sub.exclude) && sub.exclude.includes(id))
184
+ : sessionIdsOf(sub).includes(id);
185
+ if (watches) watchers.add(watcherKey(sub));
179
186
  }
180
187
  return [...watchers];
181
188
  }
182
189
 
190
+ /** "digest 30 min" / "quiet 23:00–06:00": what a subscription's options hold back, or ''. */
191
+ export function subscriptionOptionHints(sub) {
192
+ const quiet = sub?.quietHours && typeof sub.quietHours === 'object' ? sub.quietHours : null;
193
+ return [
194
+ Number(sub?.digestMinutes) > 0 ? `digest ${Number(sub.digestMinutes)} min` : '',
195
+ quiet ? `quiet ${String(quiet.from || '')}–${String(quiet.to || '')}` : '',
196
+ ].filter(Boolean);
197
+ }
198
+
183
199
  export function watchingChipText(summary) {
184
200
  if (!summary?.subscriptions?.length) return '';
185
201
  if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
@@ -188,6 +204,11 @@ export function watchingChipText(summary) {
188
204
  return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
189
205
  }
190
206
 
207
+ /** The chip's small hint: the options of the conversation's subscriptions, each named once (at most two). */
208
+ export function watchingChipHint(summary) {
209
+ return [...new Set((summary?.subscriptions || []).flatMap(subscriptionOptionHints))].slice(0, 2).join(' · ');
210
+ }
211
+
191
212
  function conversationTitle(conversations, id) {
192
213
  const title = String(conversations?.[id]?.title || '').trim();
193
214
  return title || `session ${String(id).slice(0, 8)}`;
@@ -203,9 +224,14 @@ function describeSubscription(sub, conversations) {
203
224
  const what = sub.scope === 'all'
204
225
  ? (paired ? `All sessions of ${relay}` : 'All sessions')
205
226
  : (paired ? `${relay} · ${listed}` : (listed || 'This relay'));
227
+ const quiet = sub.quietHours && typeof sub.quietHours === 'object' ? sub.quietHours : null;
228
+ const excluded = Array.isArray(sub.exclude) ? sub.exclude.length : 0;
206
229
  const extras = [
207
230
  sub.once ? 'once' : '',
208
231
  sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
232
+ sub.digestMinutes ? `digest ${sub.digestMinutes} min` : '',
233
+ quiet ? `quiet ${quiet.from}–${quiet.to} ${quiet.tz}` : '',
234
+ excluded ? `${excluded} left out` : '',
209
235
  ].filter(Boolean);
210
236
  return { what, events: (sub.events || []).join(', '), extras: extras.join(' · ') };
211
237
  }