@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,119 @@
1
+ // Relay schedules in the browser: the "scheduled" marker on a conversation's
2
+ // sidebar row and the header's "N scheduled" chip with its list and Cancel
3
+ // buttons.
4
+ //
5
+ // DOM-free on purpose, like relay-event-view.mjs: the callers own the
6
+ // elements, this module owns the decisions and the markup.
7
+ //
8
+ // The interval words mirror shared/relay-schedules.mjs, which the browser
9
+ // cannot import (only server/public is served); relay-schedule-view.test.mjs
10
+ // holds the two together.
11
+
12
+ export const RELAY_SCHEDULE_SOCKET_EVENT = 'relay_schedules';
13
+ export const RELAY_SCHEDULE_MESSAGE_KIND = 'relay-schedule';
14
+
15
+ function defaultEscape(value) {
16
+ return String(value ?? '')
17
+ .replace(/&/g, '&')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ .replace(/'/g, '&#39;');
22
+ }
23
+
24
+ function defaultFormatTime(iso) {
25
+ const at = new Date(iso);
26
+ if (!Number.isFinite(at.getTime())) return '';
27
+ const sameDay = new Date().toDateString() === at.toDateString();
28
+ return at.toLocaleString([], sameDay
29
+ ? { hour: '2-digit', minute: '2-digit' }
30
+ : { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
31
+ }
32
+
33
+ function activeList(schedules) {
34
+ return (Array.isArray(schedules) ? schedules : []).filter((schedule) => schedule && schedule.id && !schedule.endedAt);
35
+ }
36
+
37
+ /** "every 30 min", "every 2 h", "every day", "every 7 days". */
38
+ export function describeScheduleInterval(everyMinutes) {
39
+ const minutes = Number(everyMinutes);
40
+ if (!(minutes > 0)) return '';
41
+ if (minutes % 1440 === 0) return minutes === 1440 ? 'every day' : `every ${minutes / 1440} days`;
42
+ if (minutes % 60 === 0) return minutes === 60 ? 'every hour' : `every ${minutes / 60} h`;
43
+ return `every ${minutes} min`;
44
+ }
45
+
46
+ /** The schedules that fire into a conversation. */
47
+ export function schedulesInto(schedules, conversationId) {
48
+ const id = String(conversationId || '').trim();
49
+ if (!id) return [];
50
+ return activeList(schedules).filter((schedule) => schedule.session?.id === id);
51
+ }
52
+
53
+ /** The schedules that fire into a conversation or that it set for another. */
54
+ export function schedulesOf(schedules, conversationId) {
55
+ const id = String(conversationId || '').trim();
56
+ if (!id) return [];
57
+ return activeList(schedules).filter((schedule) => schedule.session?.id === id || schedule.setBy?.id === id);
58
+ }
59
+
60
+ /** "every 30 min · next 10:30", or "once · 06:30". */
61
+ export function describeScheduleWhen(schedule, { formatTime = defaultFormatTime } = {}) {
62
+ const next = schedule?.nextFireAt ? formatTime(schedule.nextFireAt) : '';
63
+ const every = schedule?.once ? '' : describeScheduleInterval(schedule?.everyMinutes);
64
+ if (every) return next ? `${every} · next ${next}` : every;
65
+ return next ? `once · ${next}` : 'once';
66
+ }
67
+
68
+ /** The earliest next time among the schedules, or null. */
69
+ export function nextScheduledAt(schedules) {
70
+ const times = activeList(schedules).map((schedule) => Date.parse(schedule.nextFireAt || '')).filter(Number.isFinite);
71
+ return times.length ? new Date(Math.min(...times)).toISOString() : null;
72
+ }
73
+
74
+ export function scheduleChipText(schedules) {
75
+ const count = activeList(schedules).length;
76
+ return count ? `${count} scheduled` : '';
77
+ }
78
+
79
+ function conversationTitle(conversations, id, fallback = '') {
80
+ const title = String(conversations?.[id]?.title || fallback || '').trim();
81
+ return title || `session ${String(id).slice(0, 8)}`;
82
+ }
83
+
84
+ /** The sidebar's marker for a conversation a schedule fires into, or ''. */
85
+ export function renderScheduledBadgeHtml(schedules, conversationId, { escHtml = defaultEscape, formatTime = defaultFormatTime } = {}) {
86
+ const into = schedulesInto(schedules, conversationId);
87
+ if (!into.length) return '';
88
+ const next = nextScheduledAt(into);
89
+ const hint = `${into.length} schedule${into.length === 1 ? '' : 's'}${next ? ` · next ${formatTime(next)}` : ''}`;
90
+ return `<span class="conv-scheduled-badge" title="${escHtml(hint)}">scheduled</span>`;
91
+ }
92
+
93
+ /**
94
+ * The chip's list for the conversation on screen: one row per schedule, what
95
+ * it is called and sends, where it goes or who set it, when it fires next,
96
+ * and its Cancel button.
97
+ */
98
+ export function renderSchedulePanelHtml(schedules, conversationId, { conversations = null, escHtml = defaultEscape, formatTime = defaultFormatTime } = {}) {
99
+ const id = String(conversationId || '').trim();
100
+ const rows = schedulesOf(schedules, id).map((schedule) => {
101
+ const name = String(schedule.title || '').trim() || String(schedule.text || '').trim().split(/\r?\n/)[0] || schedule.id;
102
+ const where = schedule.session?.id !== id
103
+ ? `for ${conversationTitle(conversations, schedule.session?.id, schedule.session?.title)}`
104
+ : (schedule.setBy?.id ? `set by ${conversationTitle(conversations, schedule.setBy.id, schedule.setBy.title)}` : '');
105
+ // Held: its setter's agent has prompted other agents as often as it may
106
+ // before you write in that conversation.
107
+ const held = schedule.heldForApproval
108
+ ? `waiting until you write in ${conversationTitle(conversations, schedule.setBy?.id, schedule.setBy?.title)}`
109
+ : '';
110
+ const details = [held || describeScheduleWhen(schedule, { formatTime }), where, schedule.skippedCount ? `${schedule.skippedCount} skipped` : '']
111
+ .filter(Boolean).join(' · ');
112
+ return '<li class="relay-watch-item relay-schedule-item">'
113
+ + `<span class="relay-watch-what" title="${escHtml(schedule.text || '')}">${escHtml(name)}</span>`
114
+ + `<span class="relay-watch-events">${escHtml(details)}</span>`
115
+ + `<button type="button" class="relay-schedule-cancel" data-schedule-id="${escHtml(schedule.id)}">Cancel</button>`
116
+ + '</li>';
117
+ }).join('');
118
+ return `<ul class="relay-watch-list">${rows}</ul>`;
119
+ }
@@ -0,0 +1,172 @@
1
+ // Relay schedules on the page: the header's "N scheduled" chip with its list
2
+ // and Cancel buttons (on a conversation a schedule fires into or that set
3
+ // one), and the "scheduled" marker on the sidebar row of a conversation a
4
+ // schedule fires into. The relay sends the whole active list on every change
5
+ // (socket `relay_schedules`); 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
+ nextScheduledAt,
11
+ renderScheduledBadgeHtml,
12
+ renderSchedulePanelHtml,
13
+ scheduleChipText,
14
+ schedulesOf,
15
+ } from './relay-schedule-view.mjs';
16
+
17
+ let schedules = [];
18
+ let renderedChipKey = '';
19
+ let panelOpen = false;
20
+ let panelConversationId = '';
21
+ const changeListeners = new Set();
22
+
23
+ /** Runs after every change of the list (the sidebar re-renders its markers). */
24
+ export function onRelaySchedulesChanged(listener) {
25
+ changeListeners.add(listener);
26
+ return () => changeListeners.delete(listener);
27
+ }
28
+
29
+ export function applyRelaySchedules(list) {
30
+ schedules = Array.isArray(list) ? list.filter((schedule) => schedule && typeof schedule === 'object') : [];
31
+ syncRelayScheduleHeader();
32
+ for (const listener of changeListeners) {
33
+ try { listener(schedules); } catch {}
34
+ }
35
+ }
36
+
37
+ export async function loadRelaySchedules() {
38
+ if (IS_SHARED_VIEW) return;
39
+ const response = await apiFetch('/api/relay-schedules');
40
+ if (response && Array.isArray(response.schedules)) applyRelaySchedules(response.schedules);
41
+ }
42
+
43
+ /** The sidebar's marker for one conversation. */
44
+ export function relayScheduleMarkerHtml(conversationId) {
45
+ if (IS_SHARED_VIEW) return '';
46
+ return renderScheduledBadgeHtml(schedules, conversationId, { escHtml });
47
+ }
48
+
49
+ async function cancelSchedule(scheduleId, button) {
50
+ if (!scheduleId) return;
51
+ if (button) button.disabled = true;
52
+ const response = await apiFetch(`/api/relay-schedules/${encodeURIComponent(scheduleId)}`, { method: 'DELETE' });
53
+ if (!response?.ok) {
54
+ if (button) button.disabled = false;
55
+ showTransientRelayNotice?.('The schedule could not be cancelled. Try again in a moment.');
56
+ return;
57
+ }
58
+ // The socket brings the new list; this page does not wait for it.
59
+ applyRelaySchedules(schedules.filter((schedule) => schedule.id !== scheduleId));
60
+ }
61
+
62
+ // A fixed popover on <body>, placed under the chip, like the subscriptions'
63
+ // list: the title slot clips its overflow.
64
+ function ensurePanel() {
65
+ let panel = document.getElementById('relay-schedule-panel');
66
+ if (panel) return panel;
67
+ panel = document.createElement('div');
68
+ panel.id = 'relay-schedule-panel';
69
+ panel.className = 'relay-watch-panel';
70
+ panel.hidden = true;
71
+ document.body.appendChild(panel);
72
+ panel.addEventListener('click', (event) => {
73
+ const target = event.target instanceof Element ? event.target : null;
74
+ const cancelButton = target?.closest?.('.relay-schedule-cancel');
75
+ if (!cancelButton) return;
76
+ void cancelSchedule(cancelButton.getAttribute('data-schedule-id'), cancelButton);
77
+ });
78
+ document.addEventListener('click', (event) => {
79
+ if (!panelOpen) return;
80
+ // composedPath: a Cancel click may have re-rendered the panel already.
81
+ const path = event.composedPath();
82
+ if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-schedule-chip'))) return;
83
+ setPanelOpen(false);
84
+ });
85
+ document.addEventListener('keydown', (event) => {
86
+ if (!panelOpen || event.key !== 'Escape') return;
87
+ event.stopPropagation();
88
+ setPanelOpen(false);
89
+ document.querySelector('#chat-title-relay-schedules .relay-schedule-chip')?.focus?.();
90
+ }, true);
91
+ window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
92
+ return panel;
93
+ }
94
+
95
+ function setPanelOpen(open) {
96
+ panelOpen = Boolean(open);
97
+ renderedChipKey = '';
98
+ syncRelayScheduleHeader();
99
+ }
100
+
101
+ function placePanel() {
102
+ const panel = document.getElementById('relay-schedule-panel');
103
+ const chip = document.querySelector('#chat-title-relay-schedules .relay-schedule-chip');
104
+ if (!panel || panel.hidden) return;
105
+ const rect = chip?.getBoundingClientRect?.();
106
+ if (!rect || (!rect.width && !rect.height)) {
107
+ panel.hidden = true;
108
+ return;
109
+ }
110
+ const margin = 8;
111
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
112
+ const top = Math.round(rect.bottom + 4);
113
+ panel.style.top = `${top}px`;
114
+ panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
115
+ panel.style.left = '0px';
116
+ const width = panel.offsetWidth;
117
+ const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
118
+ panel.style.left = `${Math.round(left)}px`;
119
+ }
120
+
121
+ function ensureHeaderElements() {
122
+ const slot = document.getElementById('chat-title-slot');
123
+ if (!slot) return null;
124
+ let line = document.getElementById('chat-title-relay-schedules');
125
+ if (!line) {
126
+ line = document.createElement('span');
127
+ line.id = 'chat-title-relay-schedules';
128
+ line.hidden = true;
129
+ const after = document.getElementById('chat-title-relay-events') || document.getElementById('chat-title-origin');
130
+ if (after?.nextSibling) slot.insertBefore(line, after.nextSibling);
131
+ else slot.appendChild(line);
132
+ line.addEventListener('click', (event) => {
133
+ const target = event.target instanceof Element ? event.target : null;
134
+ if (target?.closest?.('.relay-schedule-chip')) {
135
+ event.stopPropagation();
136
+ setPanelOpen(!panelOpen);
137
+ }
138
+ });
139
+ }
140
+ return line;
141
+ }
142
+
143
+ /** The header's chip for the conversation on screen. Idempotent. */
144
+ export function syncRelayScheduleHeader() {
145
+ const line = ensureHeaderElements();
146
+ if (!line) return;
147
+ const panel = ensurePanel();
148
+ const id = String(currentConvId || '').trim();
149
+ // Another conversation on screen closes the list.
150
+ if (id !== panelConversationId) panelOpen = false;
151
+ panelConversationId = id;
152
+ const mine = IS_SHARED_VIEW || !id ? [] : schedulesOf(schedules, id);
153
+ const chipText = scheduleChipText(mine);
154
+ if (!chipText) panelOpen = false;
155
+ const key = JSON.stringify([id, chipText, panelOpen, mine.map((schedule) => [schedule.id, schedule.nextFireAt, schedule.skippedCount || 0])]);
156
+ if (key === renderedChipKey) return;
157
+ renderedChipKey = key;
158
+ if (!chipText) {
159
+ line.hidden = true;
160
+ line.innerHTML = '';
161
+ panel.hidden = true;
162
+ panel.innerHTML = '';
163
+ return;
164
+ }
165
+ const next = nextScheduledAt(mine);
166
+ const nextText = next ? new Date(next).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
167
+ line.innerHTML = `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with remote_relay schedule)">⏰ ${escHtml(chipText)}${nextText ? ` <span class="relay-watch-hint">next ${escHtml(nextText)}</span>` : ''}</button>`;
168
+ line.hidden = false;
169
+ panel.innerHTML = panelOpen ? renderSchedulePanelHtml(schedules, id, { conversations, escHtml }) : '';
170
+ panel.hidden = !panelOpen;
171
+ if (panelOpen) placePanel();
172
+ }
@@ -1502,6 +1502,11 @@ const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
1502
1502
  'boot-delay': 'boot delay',
1503
1503
  launcher: 'launcher edited or from an older OAR',
1504
1504
  'launcher-missing': 'launcher missing',
1505
+ user: 'account',
1506
+ 'run-level': 'run level',
1507
+ settings: 'settings',
1508
+ other: 'other parts',
1509
+ unreadable: 'unreadable',
1505
1510
  });
1506
1511
 
1507
1512
  function windowsAutostartStatusText() {
@@ -1511,21 +1516,30 @@ function windowsAutostartStatusText() {
1511
1516
  }
1512
1517
  if (boot?.lastError) return boot.lastError;
1513
1518
  if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
1519
+ const [startupEntry] = windowsAutostartState?.signinHandMade || [];
1520
+ if (startupEntry) {
1521
+ const starts = startupEntry.otherInstall ? ` (it starts the OAR install in ${startupEntry.otherInstall})` : '';
1522
+ return `A Startup entry OAR did not make is there: ${startupEntry.path}${starts}. OAR leaves it as it is; to replace it, keeping a copy: oar autostart signin --replace.`;
1523
+ }
1514
1524
  if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
1515
1525
  return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
1516
1526
  }
1527
+ // OAR never deletes or writes over an entry it did not make (as `oar autostart`).
1528
+ const replaceHint = 'OAR leaves it as it is; to replace it, keeping a copy: oar autostart boot --replace';
1517
1529
  if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
1518
1530
  const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
1519
- return `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates both and keeps a changed launcher as a .bak copy.`;
1531
+ return boot.handMade?.length
1532
+ ? `The boot task or its launcher was changed by hand (${labels.join(', ')}). ${replaceHint}.`
1533
+ : `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates it.`;
1520
1534
  }
1521
1535
  if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
1522
1536
  return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
1523
1537
  }
1524
1538
  if (boot?.taskStatus === 'foreign') {
1525
- return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
1539
+ return `A task named "${boot.taskName}" exists but was not created by OAR. ${replaceHint}.`;
1526
1540
  }
1527
1541
  if (boot?.legacyTaskPresent && windowsAutostartState?.mode !== 'boot') {
1528
- return 'A pre-rebrand boot task exists. Selecting "At system startup" migrates it (one admin confirmation).';
1542
+ return `A pre-rebrand boot task exists. ${replaceHint}.`;
1529
1543
  }
1530
1544
  return '';
1531
1545
  }
@@ -38,6 +38,9 @@ import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
38
38
  import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
39
39
  import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
40
40
  import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
41
+ import { applyRelaySchedules, loadRelaySchedules } from './relay-schedules-ui.js';
42
+ import { noteInboxQuestionsChanged, noteRelayAskUpdated, refreshInbox } from './inbox-ui.js';
43
+ import { RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
41
44
  import {
42
45
  upsertRelayQuestion,
43
46
  loadRelayQuestions,
@@ -368,6 +371,10 @@ export async function connectSocket(overrideDeps) {
368
371
  void handleTaskConsoleReconnect();
369
372
  // Subscriptions that changed while away.
370
373
  void loadRelayEventSubscriptions();
374
+ // Schedules that changed or fired while away.
375
+ void loadRelaySchedules();
376
+ // Questions asked or answered while away.
377
+ void refreshInbox();
371
378
  // Shares made or stopped while away.
372
379
  void refreshShares().catch(() => {});
373
380
  });
@@ -618,11 +625,20 @@ export async function connectSocket(overrideDeps) {
618
625
  noteTranscriptLiveRow();
619
626
  keepLiveRow(conversationId, message, messageId);
620
627
  });
621
- socket.on('relay_question', ({ question }) => upsertRelayQuestion(question));
622
- socket.on('relay_question_updated', ({ question }) => upsertRelayQuestion(question));
628
+ socket.on('relay_question', ({ question }) => {
629
+ upsertRelayQuestion(question);
630
+ noteInboxQuestionsChanged();
631
+ });
632
+ socket.on('relay_question_updated', ({ question }) => {
633
+ upsertRelayQuestion(question);
634
+ noteInboxQuestionsChanged();
635
+ });
623
636
  socket.on('relay_question_changed', () => {
624
637
  loadRelayQuestions(currentConvId);
638
+ noteInboxQuestionsChanged();
625
639
  });
640
+ // Questions that don't block: one ask, after every change of it.
641
+ socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
626
642
  socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
627
643
  socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
628
644
  socket.on('relay_board_changed', () => {
@@ -632,6 +648,10 @@ export async function connectSocket(overrideDeps) {
632
648
  socket.on('relay_event_subscriptions', ({ subscriptions }) => {
633
649
  applyRelayEventSubscriptions(subscriptions);
634
650
  });
651
+ // Relay schedules: the whole active list, after every change.
652
+ socket.on('relay_schedules', ({ schedules }) => {
653
+ applyRelaySchedules(schedules);
654
+ });
635
655
  socket.on('background_tasks', ({ conversationId, tasks, endedTasks }) => {
636
656
  setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event: true });
637
657
  // The sidebar's row (and its group's head) count background work as
@@ -799,7 +819,7 @@ export async function connectSocket(overrideDeps) {
799
819
  applyConversationTitleUpdate(conversationId, title, updatedAt);
800
820
  syncChatTitleControls();
801
821
  });
802
- socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, senderClientId }) => {
822
+ socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, senderClientId }) => {
803
823
  if (senderClientId && senderClientId === CLIENT_ID) return;
804
824
  const id = String(conversationId || '').trim();
805
825
  if (!id || !conversations[id]) return;
@@ -821,6 +841,10 @@ export async function connectSocket(overrideDeps) {
821
841
  thinkingDisplay: thinkingDisplay === undefined
822
842
  ? (conversations[id].thinkingDisplay ?? null)
823
843
  : (thinkingDisplay ?? null),
844
+ // The owner's "agents may start sessions"; an older relay omits it.
845
+ mayCreateSessions: typeof mayCreateSessions === 'boolean'
846
+ ? mayCreateSessions
847
+ : conversations[id].mayCreateSessions === true,
824
848
  };
825
849
  if (String(currentConvId || '').trim() === id) {
826
850
  applyConversationPreferencesForConversation(id, {
@@ -222,6 +222,16 @@ export function setCurrentConv(id) {
222
222
  updateCompactButton();
223
223
  }
224
224
 
225
+ /**
226
+ * Forgets the kept empty groups before the list's first page names the ones
227
+ * still kept (one the owner dissolved meanwhile is not named again).
228
+ */
229
+ export function forgetKeptEmptyConversationGroups() {
230
+ for (const [id, group] of Object.entries(conversationGroups)) {
231
+ if (group?.keptEmptyAt) delete conversationGroups[id];
232
+ }
233
+ }
234
+
225
235
  /** Merges a list payload's `groups` (by id) into the known session groups. */
226
236
  export function mergeConversationGroups(groups) {
227
237
  if (!groups || typeof groups !== 'object') return;
@@ -240,13 +250,15 @@ export function forgetConversationGroup(groupId) {
240
250
  /**
241
251
  * Keeps only the groups that a loaded row still belongs to: after a list
242
252
  * refresh a dissolved group has no row left, and the picker must not offer
243
- * it. Returns the ids dropped.
253
+ * it. An owner's group an agent emptied (`keptEmptyAt`) has no row and
254
+ * stays: the list's first page names it while it is kept. Returns the ids
255
+ * dropped.
244
256
  */
245
257
  export function pruneConversationGroups(keepGroupIds) {
246
258
  const keep = new Set((Array.isArray(keepGroupIds) ? keepGroupIds : []).map((id) => String(id || '').trim()).filter(Boolean));
247
259
  const dropped = [];
248
260
  for (const id of Object.keys(conversationGroups)) {
249
- if (keep.has(id)) continue;
261
+ if (keep.has(id) || conversationGroups[id]?.keptEmptyAt) continue;
250
262
  delete conversationGroups[id];
251
263
  dropped.push(id);
252
264
  }
@@ -1126,9 +1138,10 @@ function measureSidebarHeaderMinWidthPx() {
1126
1138
  sectionCount += 1;
1127
1139
  }
1128
1140
 
1129
- const statusButton = document.getElementById('status-btn');
1130
- if (isVisibleElement(statusButton)) {
1131
- const buttonStyle = window.getComputedStyle(statusButton);
1141
+ for (const id of ['status-btn', 'inbox-btn']) {
1142
+ const headerButton = document.getElementById(id);
1143
+ if (!isVisibleElement(headerButton)) continue;
1144
+ const buttonStyle = window.getComputedStyle(headerButton);
1132
1145
  neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
1133
1146
  sectionCount += 1;
1134
1147
  }
@@ -788,6 +788,151 @@
788
788
  }
789
789
  .status-empty { color: var(--muted); text-align: center; }
790
790
 
791
+ /* The Inbox: the sidebar button with its count, the view in the
792
+ transcript's place (status-view's frame), and the card a question that
793
+ does not block shows in its session. Every text in it is an agent's,
794
+ set as plain text. */
795
+ #inbox-btn {
796
+ flex: 0 0 auto;
797
+ display: inline-flex;
798
+ align-items: center;
799
+ gap: 6px;
800
+ min-width: 72px;
801
+ padding: 10px 12px;
802
+ background: var(--bg3);
803
+ color: var(--text);
804
+ border: 1px solid var(--border);
805
+ border-radius: 8px;
806
+ cursor: pointer;
807
+ font-weight: 600;
808
+ font-size: var(--fs-meta-md);
809
+ white-space: nowrap;
810
+ }
811
+ #inbox-btn:hover, #inbox-btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
812
+ .inbox-count {
813
+ min-width: 18px;
814
+ padding: 1px 6px;
815
+ border-radius: 999px;
816
+ background: var(--accent);
817
+ color: var(--bg);
818
+ font-size: var(--fs-meta-xs);
819
+ font-variant-numeric: tabular-nums;
820
+ text-align: center;
821
+ }
822
+ .inbox-count:empty { display: none; }
823
+ body.inbox-view-active #input-area { display: none; }
824
+ body.inbox-view-active #pending-question-banner { display: none; }
825
+ .inbox-view { display: grid; gap: 14px; padding: 4px 0 24px; }
826
+ .inbox-filter { display: flex; gap: 6px; flex-wrap: wrap; }
827
+ .inbox-filter-option {
828
+ padding: 6px 12px;
829
+ border: 1px solid var(--border);
830
+ border-radius: 999px;
831
+ background: var(--bg2);
832
+ color: var(--text);
833
+ cursor: pointer;
834
+ font-size: var(--fs-meta-sm);
835
+ }
836
+ .inbox-filter-option.active { border-color: var(--accent); color: var(--accent); }
837
+ .inbox-relay { display: grid; gap: 10px; }
838
+ .inbox-relay-name { margin: 6px 0 0; font-size: var(--fs-meta-md); color: var(--muted); font-weight: 600; }
839
+ .inbox-relay-unreachable, .inbox-empty { margin: 0; color: var(--muted); font-size: var(--fs-meta-sm); }
840
+ .inbox-divider {
841
+ margin-top: 6px;
842
+ padding-top: 8px;
843
+ border-top: 1px dashed var(--border);
844
+ color: var(--muted);
845
+ font-size: var(--fs-meta-xs);
846
+ text-transform: uppercase;
847
+ letter-spacing: 0.04em;
848
+ }
849
+ .inbox-item {
850
+ display: grid;
851
+ gap: 8px;
852
+ min-width: 0;
853
+ padding: 12px 14px;
854
+ border: 1px solid var(--border);
855
+ border-radius: 10px;
856
+ background: var(--bg2);
857
+ }
858
+ .inbox-item[data-status="answered"], .inbox-item[data-status="dismissed"] { opacity: 0.75; }
859
+ .inbox-item-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; color: var(--muted); font-size: var(--fs-meta-xs); }
860
+ .inbox-item-kind { font-weight: 600; color: var(--accent); }
861
+ .inbox-item-urgent { color: var(--danger); font-weight: 600; text-transform: uppercase; }
862
+ .inbox-item-relay { padding: 0 6px; border: 1px solid var(--border); border-radius: 999px; }
863
+ .inbox-item-session { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; font-size: var(--fs-meta-sm); }
864
+ .inbox-open-session {
865
+ padding: 0;
866
+ border: 0;
867
+ background: none;
868
+ color: var(--accent);
869
+ cursor: pointer;
870
+ font: inherit;
871
+ text-align: left;
872
+ text-decoration: underline;
873
+ }
874
+ .inbox-open-there { color: var(--accent); }
875
+ .inbox-item-asker { color: var(--muted); }
876
+ .inbox-item-header { font-weight: 600; font-size: var(--fs-meta-sm); }
877
+ .inbox-item-question, .inbox-item-recommendation { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
878
+ .inbox-item-recommendation { color: var(--muted); font-size: var(--fs-meta-sm); }
879
+ .inbox-item-detail summary { cursor: pointer; color: var(--muted); font-size: var(--fs-meta-sm); }
880
+ .inbox-item-detail-text {
881
+ margin: 6px 0 0;
882
+ padding: 8px 10px;
883
+ max-height: 320px;
884
+ overflow: auto;
885
+ border-radius: 8px;
886
+ background: var(--bg3);
887
+ white-space: pre-wrap;
888
+ overflow-wrap: anywhere;
889
+ font-size: var(--fs-meta-sm);
890
+ }
891
+ .inbox-item-outcome { color: var(--success); font-size: var(--fs-meta-sm); overflow-wrap: anywhere; }
892
+ .inbox-item-options, .inbox-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
893
+ .inbox-item-reply { display: grid; gap: 8px; }
894
+ .inbox-answer-text {
895
+ width: 100%;
896
+ box-sizing: border-box;
897
+ padding: 8px 10px;
898
+ border: 1px solid var(--border);
899
+ border-radius: 8px;
900
+ background: var(--bg);
901
+ color: var(--text);
902
+ font: inherit;
903
+ resize: vertical;
904
+ }
905
+ .inbox-choice, .inbox-answer, .inbox-dismiss, .inbox-open-session-button, .inbox-open-there-button {
906
+ min-height: 36px;
907
+ padding: 6px 14px;
908
+ border: 1px solid var(--border);
909
+ border-radius: 8px;
910
+ background: var(--bg3);
911
+ color: var(--text);
912
+ cursor: pointer;
913
+ font: inherit;
914
+ text-decoration: none;
915
+ display: inline-flex;
916
+ align-items: center;
917
+ gap: 6px;
918
+ }
919
+ .inbox-choice.recommended { border-color: var(--accent); }
920
+ .inbox-recommended-tag { color: var(--accent); font-size: var(--fs-meta-xs); }
921
+ .inbox-answer { border-color: var(--accent); color: var(--accent); }
922
+ .inbox-dismiss { color: var(--muted); }
923
+ .inbox-view button:disabled, .relay-ask-container button:disabled { opacity: 0.5; cursor: wait; }
924
+ .relay-ask-container { max-width: 760px; }
925
+ /* The phone: one column, buttons the full width. */
926
+ @media (max-width: 680px) {
927
+ #inbox-btn { min-width: 64px; padding: 9px 10px; }
928
+ .inbox-item-options, .inbox-item-actions { flex-direction: column; }
929
+ .inbox-choice, .inbox-answer, .inbox-dismiss, .inbox-open-session-button, .inbox-open-there-button {
930
+ width: 100%;
931
+ justify-content: center;
932
+ }
933
+ .inbox-item { padding: 12px; }
934
+ }
935
+
791
936
  #conv-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
792
937
  .conv-list-footer {
793
938
  padding: 10px 12px 6px;
@@ -2508,6 +2653,9 @@
2508
2653
  .ctx-attribution-select { font-size: var(--fs-meta-sm); max-width: 100%; }
2509
2654
  .ctx-attribution-note { margin-top: 6px; font-size: var(--fs-meta-sm); color: var(--muted); line-height: 1.4; overflow-wrap: anywhere; }
2510
2655
  .ctx-attribution-note code { font-size: inherit; }
2656
+ .ctx-agent-sessions { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; }
2657
+ .ctx-agent-sessions-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; cursor: pointer; }
2658
+ .ctx-agent-sessions-row input { margin-top: 2px; }
2511
2659
  .ctx-thinking {
2512
2660
  margin-top: 14px;
2513
2661
  padding-top: 12px;
@@ -3789,11 +3937,17 @@
3789
3937
  font-size: var(--fs-meta-xs); line-height: 1.2; color: var(--muted); position: relative;
3790
3938
  }
3791
3939
  #chat-title-relay-events[hidden] { display: none; }
3940
+ #chat-title-relay-schedules {
3941
+ display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 2px;
3942
+ font-size: var(--fs-meta-xs); line-height: 1.2; color: var(--muted); position: relative;
3943
+ }
3944
+ #chat-title-relay-schedules[hidden] { display: none; }
3792
3945
  .relay-watch-panel[hidden] { display: none; }
3793
3946
  .relay-watch-chip {
3794
3947
  font: inherit; color: var(--accent); background: transparent; cursor: pointer;
3795
3948
  border: 1px solid var(--border); border-radius: 10px; padding: 2px 8px;
3796
3949
  }
3950
+ .relay-watch-hint { font-size: var(--fs-meta-xs); color: var(--muted); }
3797
3951
  .relay-watch-panel {
3798
3952
  position: fixed; top: 0; left: 0; z-index: 60; overflow-y: auto; box-sizing: border-box;
3799
3953
  min-width: min(260px, calc(100vw - 16px)); max-width: min(calc(100vw - 16px), 480px);
@@ -3809,11 +3963,13 @@
3809
3963
  .relay-watch-item:last-child { border-bottom: none; }
3810
3964
  .relay-watch-what { color: var(--text, inherit); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3811
3965
  .relay-watch-events { grid-column: 1; font-size: var(--fs-ui-chip); }
3812
- .relay-watch-unsubscribe {
3966
+ .relay-watch-unsubscribe,
3967
+ .relay-schedule-cancel {
3813
3968
  grid-column: 2; grid-row: 1 / span 2; font: inherit; cursor: pointer;
3814
3969
  color: var(--danger); background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px;
3815
3970
  }
3816
- .conv-watched-badge {
3971
+ .conv-watched-badge,
3972
+ .conv-scheduled-badge {
3817
3973
  flex: 0 0 auto; font-size: var(--fs-ui-chip); color: var(--accent);
3818
3974
  border: 1px solid var(--border); border-radius: 8px; padding: 0 5px;
3819
3975
  }
@@ -6876,6 +7032,7 @@
6876
7032
  <span id="cli-dot" title="CLI status"></span>
6877
7033
  <button id="new-conv-btn" onclick="newConversation()">+ New Chat</button>
6878
7034
  <button id="status-btn" type="button" onclick="window.toggleStatusView()">Status</button>
7035
+ <button id="inbox-btn" type="button" onclick="window.toggleInboxView()" aria-label="Inbox" aria-pressed="false">Inbox<span id="inbox-count" class="inbox-count"></span></button>
6879
7036
  <div id="sidebar-actions">
6880
7037
  <button id="install-btn" class="header-icon-btn" onclick="promptInstallApp()" title="Install app to home screen" aria-label="Install app">⬇</button>
6881
7038
  </div>