@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,141 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: questions that don't block (services/relay-ask-service.mjs).
5
+ *
6
+ * `relay_asks`: one row per question an agent asked the owner with
7
+ * remote_relay's `ask`. Not a `relay_questions` row: those belong to a turn
8
+ * (queue_id) that waits on them; an ask belongs to a session and blocks
9
+ * nothing.
10
+ *
11
+ * - `relay_id`: 'self' for an ask kept here (its session lives on this
12
+ * relay); a paired relay's registry id for this relay's copy of an ask its
13
+ * agent put on that relay (a mirror: `remote_id` is the ask's id there, and
14
+ * this relay looks for the answer until it has it).
15
+ * - `conversation_id`: the session the ask is in (on that relay for a
16
+ * mirror, `conversation_title` its title there).
17
+ * - `created_by`: the conversation here whose agent asked (NULL when a paired
18
+ * relay's agent did; `creator_json` then names that relay and its
19
+ * conversation, `{ relayId, relayName, conversationId, conversationTitle }`).
20
+ * - `question`, `options_json`, `recommendation`, `detail`, `urgency`
21
+ * ('normal' | 'now'): as the agent wrote them, checked
22
+ * (shared/relay-asks.mjs).
23
+ * - `status`: pending, answered, dismissed, withdrawn (by the agent that
24
+ * asked), cancelled (its session was archived or deleted); `answer`,
25
+ * `choice`, `answered_at`, `answered_via` (the relay whose Inbox it came
26
+ * from, NULL for this relay's own UI).
27
+ * - `notified_at`: when the asking side was told the outcome (for a mirror:
28
+ * when its creator here got question_answered).
29
+ *
30
+ * Additive and idempotent. Safe to run on every boot.
31
+ *
32
+ * Usage: node server/migrations/0023-relay-asks.mjs [path/to/copilot.db]
33
+ */
34
+
35
+ import { fileURLToPath } from 'url';
36
+ import path from 'path';
37
+ import process from 'process';
38
+ import Database from 'better-sqlite3';
39
+
40
+ export const RELAY_ASK_COLUMNS = Object.freeze([
41
+ 'id',
42
+ 'relay_id',
43
+ 'remote_id',
44
+ 'conversation_id',
45
+ 'conversation_title',
46
+ 'created_by',
47
+ 'creator_json',
48
+ 'question',
49
+ 'options_json',
50
+ 'recommendation',
51
+ 'detail',
52
+ 'urgency',
53
+ 'status',
54
+ 'answer',
55
+ 'choice',
56
+ 'answered_at',
57
+ 'answered_via',
58
+ 'notified_at',
59
+ 'created_at',
60
+ 'updated_at',
61
+ ]);
62
+
63
+ function tableExists(db, name) {
64
+ return !!db
65
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
66
+ .get(name);
67
+ }
68
+
69
+ /** What keeps the service from using the table: '' when nothing does. */
70
+ export function relayAsksTableProblem(db) {
71
+ if (!tableExists(db, 'relay_asks')) return 'no relay_asks table';
72
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_asks)`).all().map((column) => column.name));
73
+ const missing = RELAY_ASK_COLUMNS.filter((name) => !present.has(name));
74
+ return missing.length ? `relay_asks lacks ${missing.join(', ')}` : '';
75
+ }
76
+
77
+ export function migrateRelayAsks(db) {
78
+ let tablesAdded = 0;
79
+ if (!tableExists(db, 'relay_asks')) {
80
+ db.exec(`
81
+ CREATE TABLE relay_asks (
82
+ id TEXT PRIMARY KEY,
83
+ relay_id TEXT NOT NULL DEFAULT 'self',
84
+ remote_id TEXT,
85
+ conversation_id TEXT NOT NULL,
86
+ conversation_title TEXT,
87
+ created_by TEXT,
88
+ creator_json TEXT,
89
+ question TEXT NOT NULL,
90
+ options_json TEXT,
91
+ recommendation TEXT,
92
+ detail TEXT,
93
+ urgency TEXT NOT NULL DEFAULT 'normal' CHECK (urgency IN ('normal', 'now')),
94
+ status TEXT NOT NULL DEFAULT 'pending'
95
+ CHECK (status IN ('pending', 'answered', 'dismissed', 'withdrawn', 'cancelled')),
96
+ answer TEXT,
97
+ choice TEXT,
98
+ answered_at TEXT,
99
+ answered_via TEXT,
100
+ notified_at TEXT,
101
+ created_at TEXT NOT NULL,
102
+ updated_at TEXT NOT NULL,
103
+ CHECK (relay_id = 'self' OR remote_id IS NOT NULL)
104
+ )
105
+ `);
106
+ tablesAdded += 1;
107
+ }
108
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_status ON relay_asks(relay_id, status, created_at)`);
109
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_conversation ON relay_asks(conversation_id, status)`);
110
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_asks_creator ON relay_asks(created_by, status)`);
111
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_relay_asks_remote ON relay_asks(relay_id, remote_id) WHERE remote_id IS NOT NULL`);
112
+ return { applied: tablesAdded > 0, tablesAdded };
113
+ }
114
+
115
+ function defaultDbPath() {
116
+ const here = path.dirname(fileURLToPath(import.meta.url));
117
+ return path.resolve(here, '..', 'data', 'copilot.db');
118
+ }
119
+
120
+ export function migrate(dbPath, options = {}) {
121
+ const db = new Database(dbPath, options);
122
+ try {
123
+ return migrateRelayAsks(db);
124
+ } finally {
125
+ db.close();
126
+ }
127
+ }
128
+
129
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
130
+ if (isMain) {
131
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
132
+ try {
133
+ const result = migrate(dbPath);
134
+ console.log(`Migration 0023 on ${dbPath}`);
135
+ console.log(result.applied ? ` relay asks: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
136
+ process.exit(0);
137
+ } catch (err) {
138
+ console.error(`Migration 0023 failed: ${err?.message || err}`);
139
+ process.exit(1);
140
+ }
141
+ }
@@ -16,6 +16,7 @@ import { isChatInteractionHeld } from './selection-guard.mjs';
16
16
  import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
17
  import { questionAcceptsFreeText } from './question-answer-rule.mjs';
18
18
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
19
+ import { renderSessionAskCards } from './inbox-ui.js';
19
20
 
20
21
  let relayQuestionRenderHash = '';
21
22
  let renderedRelayQuestionIds = new Set();
@@ -215,6 +216,8 @@ function scrollQuestionCardIntoMessages(el, target) {
215
216
  export function renderRelayQuestions() {
216
217
  const el = document.getElementById('messages');
217
218
  if (!el) return;
219
+ // The questions that don't block go first: the cards a turn waits on stay last.
220
+ renderSessionAskCards();
218
221
  const previousScrollTop = el.scrollTop;
219
222
  // A card is shown to a reader who is at the end. Anyone further up stays
220
223
  // where they are; the jump-to-bottom button says that a question waits.
@@ -238,6 +238,7 @@ import { initScrollbarHover } from './scrollbar-hover.mjs';
238
238
  import {
239
239
  autoCompactWindowNotes,
240
240
  renderAutoCompactControlHtml,
241
+ renderAgentSessionsControlHtml,
241
242
  renderAutoCompactNotesHtml,
242
243
  renderContextUsageHtml,
243
244
  renderThinkingControlHtml,
@@ -401,6 +402,8 @@ import { openStopSharingConfirmation } from './shared-conversations-settings.js'
401
402
  import { showAbout, syncAboutMenuLabel } from './about-ui.js';
402
403
  import { syncConversationOriginHeader } from './conversation-origin-ui.js';
403
404
  import { loadRelayEventSubscriptions, onRelayEventSubscriptionsChanged, syncRelayEventHeader } from './relay-event-subscriptions-ui.js';
405
+ import { loadRelaySchedules, onRelaySchedulesChanged, syncRelayScheduleHeader } from './relay-schedules-ui.js';
406
+ import { leaveInboxView, startInbox, toggleInboxView } from './inbox-ui.js';
404
407
  import {
405
408
  previewAgentSessionsMaxWait,
406
409
  saveAgentSessionsMaxWait,
@@ -3346,6 +3349,41 @@ function initAttributionControl() {
3346
3349
  });
3347
3350
  }
3348
3351
 
3352
+ // The owner's "agents in this conversation may start sessions" in the 🧠
3353
+ // modal: one request per change; the store follows the answer (other tabs
3354
+ // follow the socket event).
3355
+ let agentSessionsControlUpdateInFlight = false;
3356
+ function initAgentSessionsControl() {
3357
+ const body = document.getElementById('summary-modal-body');
3358
+ if (!body || body.dataset.agentSessionsControlBound === 'true') return;
3359
+ body.dataset.agentSessionsControlBound = 'true';
3360
+ body.addEventListener('change', async (event) => {
3361
+ const box = event.target?.closest?.('#ctx-may-create-sessions');
3362
+ if (!box) return;
3363
+ const convId = String(box.dataset.conversationId || '').trim();
3364
+ if (!convId || agentSessionsControlUpdateInFlight) return;
3365
+ const wanted = box.checked === true;
3366
+ agentSessionsControlUpdateInFlight = true;
3367
+ box.disabled = true;
3368
+ try {
3369
+ const response = await updateConversationPreferences(convId, { clientId: CLIENT_ID, mayCreateSessions: wanted });
3370
+ if (!response) throw new Error('The relay refused the change');
3371
+ const stored = response.mayCreateSessions === true;
3372
+ if (conversations[convId]) conversations[convId] = { ...conversations[convId], mayCreateSessions: stored };
3373
+ box.checked = stored;
3374
+ showTransientRelayNotice(stored
3375
+ ? 'Agents in this conversation may now start sessions.'
3376
+ : 'Agents in this conversation can no longer start sessions.');
3377
+ } catch (error) {
3378
+ box.checked = !wanted;
3379
+ showTransientRelayNotice(`Could not change the setting: ${error?.message || error}`);
3380
+ } finally {
3381
+ box.disabled = false;
3382
+ agentSessionsControlUpdateInFlight = false;
3383
+ }
3384
+ });
3385
+ }
3386
+
3349
3387
  function initThinkingControl() {
3350
3388
  const body = document.getElementById('summary-modal-body');
3351
3389
  if (!body || body.dataset.thinkingBound === '1') return;
@@ -3511,14 +3549,21 @@ async function loadContextSummaryAndRender(convId) {
3511
3549
  enabled: payload.thinkingEnabled !== false,
3512
3550
  display: payload.thinkingDisplay || 'summarized',
3513
3551
  };
3552
+ // For a session an agent started (any provider): may its agents start
3553
+ // sessions? Only meaningful there, so only shown there.
3554
+ const settingsConversationId = String(payload.resolvedConversationId || trimmedConvId || '').trim();
3555
+ const settingsConversation = settingsConversationId ? conversations[settingsConversationId] : null;
3556
+ const agentSessionsHtml = settingsConversation?.origin
3557
+ ? renderAgentSessionsControlHtml({ conversationId: settingsConversationId, mayCreateSessions: settingsConversation.mayCreateSessions === true })
3558
+ : '';
3514
3559
  const detailText = String(payload.text || '').trim();
3515
3560
  // The structured breakdown is the answer; the runtime's own text dump stays
3516
3561
  // available underneath for the details it carries that categories don't.
3517
3562
  const bodyHtml = usageHtml
3518
- ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${detailText
3563
+ ? `${usageHtml}${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}${detailText
3519
3564
  ? `<details class="ctx-usage-raw"><summary>Raw details</summary><pre>${escHtml(detailText)}</pre></details>`
3520
3565
  : ''}`
3521
- : `${autoCompactHtml}${thinkingHtml}${attributionHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3566
+ : `${autoCompactHtml}${thinkingHtml}${attributionHtml}${agentSessionsHtml}<pre>${escHtml(detailText || 'No context data available.')}</pre>`;
3522
3567
 
3523
3568
  renderSummaryModalContent({
3524
3569
  title: 'Context usage',
@@ -4387,6 +4432,8 @@ function syncChatTitleControls() {
4387
4432
  syncConversationOriginHeader();
4388
4433
  // Relay events: "watching N sessions" on a watcher, "watched" on a watched one.
4389
4434
  syncRelayEventHeader();
4435
+ // Relay schedules: "N scheduled" with its list and Cancel buttons.
4436
+ syncRelayScheduleHeader();
4390
4437
  syncChatTitleWatcherIndicator();
4391
4438
  }
4392
4439
 
@@ -4884,6 +4931,7 @@ async function initApp() {
4884
4931
  initAutoCompactWindowControl();
4885
4932
  initThinkingControl();
4886
4933
  initAttributionControl();
4934
+ initAgentSessionsControl();
4887
4935
  initModeSelector();
4888
4936
  initModelSelector();
4889
4937
  initReasoningSelector();
@@ -4965,6 +5013,11 @@ async function initApp() {
4965
5013
  // The sidebar's "watched" badges follow the subscriptions.
4966
5014
  onRelayEventSubscriptionsChanged(() => renderConvList());
4967
5015
  void loadRelayEventSubscriptions();
5016
+ // The sidebar's "scheduled" markers follow the schedules.
5017
+ onRelaySchedulesChanged(() => renderConvList());
5018
+ void loadRelaySchedules();
5019
+ // The Inbox's count, and the cards of questions that don't block.
5020
+ startInbox();
4968
5021
  const pushConversationId = consumePushConversationDeepLink();
4969
5022
  if (pushConversationId) {
4970
5023
  await openConversation(pushConversationId).catch(() => {});
@@ -5165,7 +5218,12 @@ window.toggleArchivedConversations = toggleArchivedConversations;
5165
5218
  window.refreshNewConversationCloudRepoList = refreshNewConversationCloudRepoList;
5166
5219
  window.openConversation = openConversation;
5167
5220
  window.openOriginConversation = openOriginConversation;
5168
- window.toggleStatusView = toggleStatusView;
5221
+ // The Inbox and Status share the transcript's place: opening one leaves the other.
5222
+ window.toggleStatusView = () => {
5223
+ leaveInboxView();
5224
+ return toggleStatusView();
5225
+ };
5226
+ window.toggleInboxView = toggleInboxView;
5169
5227
  window.refreshConversations = refreshConversations;
5170
5228
  window.renderConvList = renderConvList;
5171
5229
  window.handleAttachmentInput = handleAttachmentInput;
@@ -350,6 +350,23 @@ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
350
350
  `;
351
351
  }
352
352
 
353
+ /**
354
+ * The owner's switch for a session an agent started: may its agents start
355
+ * sessions themselves (the remote_relay tool's one-level rule lifted for it)?
356
+ * Only the owner sets it; no agent can.
357
+ */
358
+ export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateSessions = false } = {}) {
359
+ return `
360
+ <div class="ctx-agent-sessions">
361
+ <label class="ctx-agent-sessions-row">
362
+ <input type="checkbox" id="ctx-may-create-sessions" data-conversation-id="${escapeHtml(conversationId)}"${mayCreateSessions === true ? ' checked' : ''}>
363
+ <span>Agents in this conversation may start sessions (even though an agent started it)</span>
364
+ </label>
365
+ <div class="ctx-thinking-note">An agent started this session, so its agents cannot start sessions of their own unless you allow it here. The limit of sessions at work, your Allow for the first one and the relay's "Agents may start and use sessions" setting still apply.</div>
366
+ </div>
367
+ `;
368
+ }
369
+
353
370
  // A model limit is a round figure: 1M rather than 1.0M.
354
371
  function formatModelLimit(value) {
355
372
  const n = toNullableNumber(value);
@@ -210,12 +210,47 @@ export function pageConversationRows(rows, groups = null, { limit = 40, beforeCo
210
210
  * `isOpen(groupId, group)` decides the fold; `matches(row)` keeps rows that
211
211
  * match a filter (a group shows when any row matches: its head as context
212
212
  * with the matching members, or whole when the head or its name matches).
213
+ *
214
+ * With `emptyGroups`, an owner's group an agent emptied (`keptEmptyAt`, no
215
+ * row here) shows too: a head item without members (`group.empty`), placed
216
+ * by the time it was emptied; under a filter only when its name matches.
213
217
  */
214
218
  export function arrangeConversationList(rows, groups = null, {
215
219
  isOpen = () => true,
216
220
  matches = null,
217
221
  groupNameMatches = null,
222
+ emptyGroups = false,
218
223
  } = {}) {
224
+ const items = arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches });
225
+ if (!emptyGroups || !groups) return items;
226
+ const present = new Set((Array.isArray(rows) ? rows : []).map(rowGroupId).filter(Boolean));
227
+ const entries = typeof groups.entries === 'function' && typeof groups.get === 'function' ? [...groups.entries()] : Object.entries(groups);
228
+ const kept = entries
229
+ .map(([id, info]) => ({ id: toText(id), info }))
230
+ .filter(({ id, info }) => id && toText(info?.keptEmptyAt) && !present.has(id))
231
+ .filter(({ info }) => !matches || (typeof groupNameMatches === 'function' && groupNameMatches(info, null)))
232
+ .map(({ id, info }) => ({
233
+ kind: 'head',
234
+ conversation: null,
235
+ group: { id, info, head: null, members: [], activityMs: parseConversationTimeMs(info.keptEmptyAt), open: false, contextOnly: false, empty: true },
236
+ }))
237
+ .sort((a, b) => (b.group.activityMs - a.group.activityMs) || b.group.id.localeCompare(a.group.id));
238
+ if (!kept.length) return items;
239
+ // Merged in by time, before the first row or group that is older.
240
+ const timeOf = (item) => (item.kind === 'head' ? item.group.activityMs : rowOwnTimeMs(item.conversation));
241
+ const out = [];
242
+ let next = 0;
243
+ for (const item of items) {
244
+ if (item.kind !== 'member') {
245
+ while (next < kept.length && kept[next].group.activityMs > timeOf(item)) out.push(kept[next++]);
246
+ }
247
+ out.push(item);
248
+ }
249
+ while (next < kept.length) out.push(kept[next++]);
250
+ return out;
251
+ }
252
+
253
+ function arrangeGroupedRows(rows, groups, { isOpen, matches, groupNameMatches }) {
219
254
  const ordered = orderConversationRows(rows, groups);
220
255
  const activity = groupActivityTimes(ordered);
221
256
  const items = [];