@oar-sh/oar 0.9.15 → 0.9.17

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 (109) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/CHANGELOG.md +305 -0
  3. package/README.md +12 -11
  4. package/package.json +2 -1
  5. package/server/claude-worker/claude-attachments.mjs +7 -4
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
  8. package/server/claude-worker/claude-session-process.mjs +131 -33
  9. package/server/claude-worker/claude-session-worker-main.mjs +266 -0
  10. package/server/claude-worker/claude-session-worker.mjs +2 -253
  11. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +282 -13
  13. package/server/claude-worker/claude-usage-totals.mjs +108 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
  17. package/server/db-schema.mjs +115 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
  19. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  20. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  21. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  22. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  23. package/server/migrations/0022-relay-schedules.mjs +142 -0
  24. package/server/migrations/0023-relay-asks.mjs +141 -0
  25. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  26. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  27. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  28. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  29. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  30. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  31. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  32. package/server/public/app/api-client.js +53 -0
  33. package/server/public/app/ask-user-view.js +3 -0
  34. package/server/public/app/bootstrap.js +162 -16
  35. package/server/public/app/context-tier-options.mjs +4 -0
  36. package/server/public/app/context-usage-view.mjs +17 -0
  37. package/server/public/app/conversation-group-order.mjs +35 -0
  38. package/server/public/app/conversation-provider-indicator.mjs +7 -0
  39. package/server/public/app/conversation-view.js +2 -0
  40. package/server/public/app/deepseek-settings-ui.js +342 -0
  41. package/server/public/app/inbox-ui.js +367 -0
  42. package/server/public/app/inbox-view.mjs +370 -0
  43. package/server/public/app/journal-view.js +54 -5
  44. package/server/public/app/model-selector-options.mjs +11 -0
  45. package/server/public/app/model-vision-support.mjs +2 -0
  46. package/server/public/app/no-conversation-state.mjs +1 -1
  47. package/server/public/app/plan-usage-view.mjs +165 -13
  48. package/server/public/app/provider-readiness.mjs +4 -1
  49. package/server/public/app/relay-error-ctas.mjs +8 -0
  50. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  51. package/server/public/app/relay-event-view.mjs +33 -7
  52. package/server/public/app/relay-schedule-view.mjs +119 -0
  53. package/server/public/app/relay-schedules-ui.js +172 -0
  54. package/server/public/app/settings-modal.js +11 -0
  55. package/server/public/app/settings-tabs.js +1 -1
  56. package/server/public/app/socket-handlers.js +37 -3
  57. package/server/public/app/store.js +18 -5
  58. package/server/public/index.html +567 -35
  59. package/server/relay-tools.md +18 -3
  60. package/server/repositories/conversation-group-repository.mjs +110 -17
  61. package/server/repositories/remote-relay-repository.mjs +20 -2
  62. package/server/repositories/session-repository.mjs +7 -1
  63. package/server/routes/ask-user-routes.mjs +57 -3
  64. package/server/routes/cache-routes.mjs +6 -0
  65. package/server/routes/conversation-group-routes.mjs +19 -3
  66. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  67. package/server/routes/messages-routes.mjs +195 -18
  68. package/server/routes/relay-ask-routes.mjs +387 -0
  69. package/server/routes/relay-event-routes.mjs +3 -1
  70. package/server/routes/relay-schedule-routes.mjs +57 -0
  71. package/server/routes/sessions-routes.mjs +932 -97
  72. package/server/server-runtime.mjs +203 -11
  73. package/server/services/about-service.mjs +1 -0
  74. package/server/services/cache-rebuild-service.mjs +19 -2
  75. package/server/services/claude-session-leftovers.mjs +226 -0
  76. package/server/services/conversation-preferences-service.mjs +17 -0
  77. package/server/services/deepseek-balance-service.mjs +102 -0
  78. package/server/services/deepseek-settings-service.mjs +380 -0
  79. package/server/services/deepseek-usage-import.mjs +471 -0
  80. package/server/services/model-variant-catalog-service.mjs +1 -0
  81. package/server/services/plan-usage-contract.mjs +100 -5
  82. package/server/services/plan-usage-deepseek.mjs +931 -0
  83. package/server/services/plan-usage-service.mjs +431 -0
  84. package/server/services/provider-model-selection.mjs +1 -0
  85. package/server/services/provider-reasoning-effort.mjs +15 -2
  86. package/server/services/relay-ask-link-service.mjs +174 -0
  87. package/server/services/relay-ask-service.mjs +604 -0
  88. package/server/services/relay-event-service.mjs +589 -73
  89. package/server/services/relay-schedule-service.mjs +596 -0
  90. package/server/services/remote-relay-caller-context.mjs +89 -20
  91. package/server/services/remote-relay-dispatcher.mjs +958 -31
  92. package/server/services/session-worker-launch-service.mjs +233 -82
  93. package/server/services/worker-secret-redaction.mjs +318 -0
  94. package/shared/context-window-fallbacks.mjs +3 -0
  95. package/shared/deepseek-api.mjs +137 -0
  96. package/shared/deepseek-models.mjs +114 -0
  97. package/shared/deepseek-pricing.mjs +146 -0
  98. package/shared/host-claude-session-env.mjs +101 -0
  99. package/shared/model-id.mjs +7 -0
  100. package/shared/preview-tool-core.mjs +1 -1
  101. package/shared/provider-routing.mjs +2 -2
  102. package/shared/relay-asks.mjs +145 -0
  103. package/shared/relay-events.mjs +268 -18
  104. package/shared/relay-schedules.mjs +128 -0
  105. package/shared/remote-relay-contract.mjs +554 -26
  106. package/shared/remote-relay-tool-core.mjs +16 -5
  107. package/shared/steer-settle-failure.mjs +1 -0
  108. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  109. package/shared/worker-runtime/worker-log-file.mjs +60 -0
@@ -11,6 +11,8 @@
11
11
  // SDK-free and side-effect free: imported by the server, the remote_relay
12
12
  // contract and the browser (which renders the event rows).
13
13
 
14
+ import { RELAY_SCHEDULE_MESSAGE_KIND } from './relay-schedules.mjs';
15
+
14
16
  export const RELAY_EVENT_VERSION = 1;
15
17
 
16
18
  // `messages.kind` of the message the relay writes into the watcher's
@@ -20,6 +22,10 @@ export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
20
22
 
21
23
  export const RELAY_EVENT_TYPES = Object.freeze([
22
24
  'question_asked',
25
+ // The owner answered or dismissed a question that does not block (remote_relay
26
+ // `ask`). The asking session is always told, without a subscription; a
27
+ // subscription that takes question_asked takes it too.
28
+ 'question_answered',
23
29
  'error',
24
30
  'turn_end',
25
31
  'task_started',
@@ -51,12 +57,16 @@ const ALL_EVENT_TYPES = Object.freeze([...RELAY_EVENT_TYPES, RELAY_EVENT_HEARTBE
51
57
  // whole context; a failed one is still a task_failed.
52
58
  export const RELAY_EVENT_SUBAGENT_FINISHED_TYPE = 'subagent_finished';
53
59
 
60
+ // Not named in `events`: question_asked takes it (relayEventTypesTake).
61
+ export const RELAY_EVENT_QUESTION_ANSWERED_TYPE = 'question_answered';
62
+ const NAMED_EVENT_TYPES = RELAY_EVENT_TYPES.filter((type) => type !== RELAY_EVENT_QUESTION_ANSWERED_TYPE);
63
+
54
64
  // What `events` may name: the event types, the narrower subagent_finished
55
65
  // and the relay's own relay_restarted.
56
66
  export const RELAY_EVENT_SUBSCRIBABLE_TYPES = Object.freeze([
57
- ...RELAY_EVENT_TYPES.slice(0, RELAY_EVENT_TYPES.indexOf('task_finished') + 1),
67
+ ...NAMED_EVENT_TYPES.slice(0, NAMED_EVENT_TYPES.indexOf('task_finished') + 1),
58
68
  RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
59
- ...RELAY_EVENT_TYPES.slice(RELAY_EVENT_TYPES.indexOf('task_finished') + 1),
69
+ ...NAMED_EVENT_TYPES.slice(NAMED_EVENT_TYPES.indexOf('task_finished') + 1),
60
70
  ...RELAY_EVENT_RELAY_LEVEL_TYPES,
61
71
  ]);
62
72
 
@@ -74,9 +84,18 @@ export function isRelayLevelOnly(types) {
74
84
  return list.length > 0 && list.every((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
75
85
  }
76
86
 
77
- // The default before subagent_finished: every type but task_started. A
78
- // subscription made without events stored exactly this list.
79
- export const RELAY_EVENT_LEGACY_DEFAULT_TYPES = Object.freeze(RELAY_EVENT_TYPES.filter((type) => type !== 'task_started'));
87
+ // The default before subagent_finished: every type of then but task_started.
88
+ // A subscription made without events stored exactly this list. Written out:
89
+ // a type added since (question_answered) is not part of it.
90
+ export const RELAY_EVENT_LEGACY_DEFAULT_TYPES = Object.freeze([
91
+ 'question_asked',
92
+ 'error',
93
+ 'turn_end',
94
+ 'task_finished',
95
+ 'task_failed',
96
+ 'stalled',
97
+ 'all_idle',
98
+ ]);
80
99
 
81
100
  /**
82
101
  * The events a stored subscription takes. A list that is exactly the old
@@ -98,21 +117,25 @@ export function isLegacyDefaultRelayEventTypes(types) {
98
117
  }
99
118
 
100
119
  /**
101
- * Whether a subscription's `events` take this event: its type named, or a
102
- * task_finished of anything but a shell command under subagent_finished.
120
+ * Whether a subscription's `events` take this event: its type named, a
121
+ * question_answered under question_asked (the answer to a question it was
122
+ * told of), or a task_finished of anything but a shell command under
123
+ * subagent_finished.
103
124
  */
104
125
  export function relayEventTypesTake(types, event) {
105
126
  const list = Array.isArray(types) ? types : [];
106
127
  const type = toText(event?.type);
107
128
  if (list.includes(type)) return true;
129
+ if (type === RELAY_EVENT_QUESTION_ANSWERED_TYPE) return list.includes('question_asked');
108
130
  return type === 'task_finished'
109
131
  && list.includes(RELAY_EVENT_SUBAGENT_FINISHED_TYPE)
110
132
  && toText(event?.detail?.kind) !== 'shell';
111
133
  }
112
134
 
113
135
  // Never held back: not by the 30 s coalescing, not by the digest past the
114
- // hourly cap (they still count toward it).
115
- export const RELAY_EVENT_URGENT_TYPES = Object.freeze(['question_asked', 'error']);
136
+ // hourly cap (they still count toward it). An answer is a decision the
137
+ // asking session may be waiting for.
138
+ export const RELAY_EVENT_URGENT_TYPES = Object.freeze(['question_asked', 'question_answered', 'error']);
116
139
 
117
140
  export const RELAY_EVENT_SCOPES = Object.freeze(['sessions', 'all']);
118
141
 
@@ -125,11 +148,17 @@ export const RELAY_EVENT_LIMITS = Object.freeze({
125
148
  stalledMinutesMax: 24 * 60,
126
149
  heartbeatMinutesMin: 1,
127
150
  heartbeatMinutesMax: 24 * 60,
151
+ // Per subscription: a digest of its non-urgent events every N minutes,
152
+ // and the session ids an "all" subscription leaves out.
153
+ digestMinutesMin: 1,
154
+ digestMinutesMax: 24 * 60,
155
+ excludeMax: 50,
128
156
  matchMaxChars: 200,
129
157
  sessionsMax: 50,
130
158
  activeSubscriptionsPerWatcher: 20,
131
159
  previewChars: 300,
132
160
  questionChars: 500,
161
+ answerChars: 2000,
133
162
  optionsMax: 8,
134
163
  linesPerMessage: 40,
135
164
  // Across paired relays. The watching relay asks the watched one for new
@@ -183,6 +212,20 @@ export function isRelayEventDelivery(pending) {
183
212
  return toText(pending?.messageKind) === RELAY_EVENT_MESSAGE_KIND;
184
213
  }
185
214
 
215
+ // The relay's own automated messages that reach a worker as a turn: event
216
+ // messages and scheduled prompts (shared/relay-schedules.mjs). The dequeue
217
+ // payload names their kind, and the workers push them without the steer note.
218
+ export const RELAY_AUTOMATED_MESSAGE_KINDS = Object.freeze([RELAY_EVENT_MESSAGE_KIND, RELAY_SCHEDULE_MESSAGE_KIND]);
219
+
220
+ export function isRelayAutomatedMessageKind(kind) {
221
+ return RELAY_AUTOMATED_MESSAGE_KINDS.includes(toText(kind));
222
+ }
223
+
224
+ /** Whether a delivered queue message is one of the relay's automated messages (isRelayEventDelivery's wider sibling). */
225
+ export function isRelayAutomatedDelivery(pending) {
226
+ return isRelayAutomatedMessageKind(pending?.messageKind);
227
+ }
228
+
186
229
  /** Any type an event message can carry, the heartbeat included. */
187
230
  export function isRelayEventType(type) {
188
231
  return ALL_EVENT_TYPES.includes(toText(type));
@@ -210,6 +253,149 @@ export function compileRelayEventMatch(pattern) {
210
253
  }
211
254
  }
212
255
 
256
+ // ─── Quiet hours ─────────────────────────────────────────────────────────────
257
+ //
258
+ // A subscription's `quiet_hours` is `{ from: "23:00", to: "06:00", tz }`: a
259
+ // window of local wall-clock time in an IANA time zone, which may cross
260
+ // midnight. Non-urgent events that fall in it are held until it ends.
261
+
262
+ const CLOCK_PATTERN = /^([01]?\d|2[0-3]):([0-5]\d)$/;
263
+
264
+ /** Minutes after midnight of "HH:MM" (or "H:MM"), or null. */
265
+ export function relayEventClockMinutes(value) {
266
+ const match = CLOCK_PATTERN.exec(toText(value));
267
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
268
+ }
269
+
270
+ function clockText(minutes) {
271
+ return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
272
+ }
273
+
274
+ /** The canonical name of an IANA time zone this runtime knows, or ''. */
275
+ export function canonicalRelayEventTimeZone(value) {
276
+ const tz = toText(value);
277
+ if (!tz || tz.length > 64) return '';
278
+ try {
279
+ return new Intl.DateTimeFormat('en-US', { timeZone: tz }).resolvedOptions().timeZone || '';
280
+ } catch {
281
+ return '';
282
+ }
283
+ }
284
+
285
+ /**
286
+ * A `quiet_hours` value checked: `{ ok: true, value }` with `{ from, to, tz }`
287
+ * as "HH:MM" and the zone's canonical name, `{ ok: true, value: null }` when
288
+ * there is none, `{ ok: false, error }` otherwise. Takes the object or its
289
+ * JSON text.
290
+ */
291
+ export function normalizeRelayEventQuietHours(input) {
292
+ if (input === undefined || input === null || input === false || (typeof input === 'string' && !input.trim())) return { ok: true, value: null };
293
+ let value = input;
294
+ if (typeof value === 'string') {
295
+ try {
296
+ value = JSON.parse(value);
297
+ } catch {
298
+ return { ok: false, error: 'quiet_hours must be an object: {"from":"23:00","to":"06:00","tz":"Europe/Berlin"}' };
299
+ }
300
+ }
301
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
302
+ return { ok: false, error: 'quiet_hours must be an object: {"from":"23:00","to":"06:00","tz":"Europe/Berlin"}' };
303
+ }
304
+ // Some providers send every property of the schema, an unused one empty.
305
+ if (['from', 'to', 'tz'].every((key) => !toText(value[key]))) return { ok: true, value: null };
306
+ const from = relayEventClockMinutes(value.from);
307
+ const to = relayEventClockMinutes(value.to);
308
+ if (from === null || to === null) return { ok: false, error: 'quiet_hours from and to must be times of day as "HH:MM" (00:00 to 23:59)' };
309
+ if (from === to) return { ok: false, error: 'quiet_hours from and to must differ' };
310
+ if (!toText(value.tz)) return { ok: false, error: 'quiet_hours needs tz, an IANA time zone such as "Europe/Berlin"' };
311
+ const tz = canonicalRelayEventTimeZone(value.tz);
312
+ if (!tz) return { ok: false, error: `quiet_hours tz "${oneLine(value.tz, 64)}" is not an IANA time zone (for example "Europe/Berlin", "America/New_York", "UTC")` };
313
+ return { ok: true, value: { from: clockText(from), to: clockText(to), tz } };
314
+ }
315
+
316
+ /** "23:00–06:00 Europe/Berlin" */
317
+ export function relayEventQuietHoursText(quiet) {
318
+ return `${oneLine(quiet?.from, 5)}–${oneLine(quiet?.to, 5)} ${oneLine(quiet?.tz, 64)}`.trim();
319
+ }
320
+
321
+ const DAY_MS = 24 * 60 * 60_000;
322
+
323
+ function minuteFloor(ms) {
324
+ return Math.floor(ms / 60_000) * 60_000;
325
+ }
326
+
327
+ const zoneFormatters = new Map();
328
+
329
+ function zoneFormatter(tz) {
330
+ let formatter = zoneFormatters.get(tz);
331
+ if (!formatter) {
332
+ formatter = new Intl.DateTimeFormat('en-US', {
333
+ timeZone: tz,
334
+ hourCycle: 'h23',
335
+ year: 'numeric',
336
+ month: 'numeric',
337
+ day: 'numeric',
338
+ hour: 'numeric',
339
+ minute: 'numeric',
340
+ });
341
+ zoneFormatters.set(tz, formatter);
342
+ }
343
+ return formatter;
344
+ }
345
+
346
+ /** The wall clock in `tz` at instant `ms`, as the UTC timestamp of the same digits. */
347
+ function wallClock(ms, tz) {
348
+ const parts = {};
349
+ for (const part of zoneFormatter(tz).formatToParts(new Date(ms))) parts[part.type] = Number(part.value);
350
+ return Date.UTC(parts.year, parts.month - 1, parts.day, (parts.hour || 0) % 24, parts.minute || 0);
351
+ }
352
+
353
+ function insideWindow(minute, from, to) {
354
+ return from < to ? minute >= from && minute < to : minute >= from || minute < to;
355
+ }
356
+
357
+ function wallMinute(wall) {
358
+ const date = new Date(wall);
359
+ return date.getUTCHours() * 60 + date.getUTCMinutes();
360
+ }
361
+
362
+ /**
363
+ * When the quiet window that instant `ms` falls in ends (epoch ms), or null
364
+ * when `ms` is outside it (or the value is not a valid window). The clock
365
+ * changes: where the clocks go back and `to` comes twice, the window ends
366
+ * the first time and is not entered again in the repeated hour; where `to`
367
+ * falls in the hour the clocks skip, it ends the moment they jump. A known
368
+ * limit: a `from` inside the repeated hour starts the window on the first
369
+ * pass, and on the second pass the minutes before `from` read as outside.
370
+ */
371
+ export function relayEventQuietWindowEnd(quiet, ms) {
372
+ const from = relayEventClockMinutes(quiet?.from);
373
+ const to = relayEventClockMinutes(quiet?.to);
374
+ const tz = canonicalRelayEventTimeZone(quiet?.tz);
375
+ if (from === null || to === null || from === to || !tz || !Number.isFinite(ms)) return null;
376
+ const wall = wallClock(ms, tz);
377
+ const minute = wallMinute(wall);
378
+ if (!insideWindow(minute, from, to)) return null;
379
+ const day = Date.UTC(new Date(wall).getUTCFullYear(), new Date(wall).getUTCMonth(), new Date(wall).getUTCDate());
380
+ // Past midnight's side of a window that crosses it ends today, else tomorrow.
381
+ const target = day + (from > to && minute >= from ? DAY_MS : 0) + to * 60_000;
382
+ // The instants whose local time reads `target`, under the offsets in force
383
+ // around it; the earliest is the end. Already past it, `ms` is in the
384
+ // repeated hour after the window ended: outside.
385
+ const offsets = new Set([-DAY_MS, 0, DAY_MS].map((shift) => wallClock(target + shift, tz) - minuteFloor(target + shift)));
386
+ const ends = [...offsets]
387
+ .map((offset) => target - offset)
388
+ .filter((instant) => wallClock(instant, tz) === target)
389
+ .sort((a, b) => a - b);
390
+ if (ends.length) return ends[0] > ms ? ends[0] : null;
391
+ // `to` does not exist that day (the clocks skip it): the first minute
392
+ // outside the window.
393
+ for (let instant = minuteFloor(ms) + 60_000; instant <= ms + 2 * DAY_MS; instant += 60_000) {
394
+ if (!insideWindow(wallMinute(wallClock(instant, tz)), from, to)) return instant;
395
+ }
396
+ return null;
397
+ }
398
+
213
399
  /** The text a `match` is tried on, or null when the event has none. */
214
400
  export function relayEventMatchText(event) {
215
401
  const detail = event?.detail || {};
@@ -349,6 +535,24 @@ export function formatRelayEventLine(event) {
349
535
  if (options.length) parts.push(`options: ${options.map((option) => `[${option}]`).join(' ')}`);
350
536
  if (detail.approvalCard) parts.push('(an approval card: only the user can answer it)');
351
537
  else if (oneLine(detail.questionId, 80)) parts.push(`(question_id ${oneLine(detail.questionId, 80)})`);
538
+ if (detail.kind === 'ask') parts.push('(asked with ask: it does not block that session)');
539
+ return parts.join(' ');
540
+ }
541
+ case 'question_answered': {
542
+ const id = oneLine(detail.questionId, 80);
543
+ const parts = [`${head}${id ? ` (question_id ${id})` : ''} ${quote(detail.prompt, RELAY_EVENT_LIMITS.questionChars)}`];
544
+ if (detail.dismissed === true) {
545
+ parts.push('→ the user dismissed it without an answer; decide yourself or ask again later');
546
+ } else {
547
+ const choice = oneLine(detail.choice, 120);
548
+ const answer = oneLine(detail.answer, RELAY_EVENT_LIMITS.answerChars);
549
+ parts.push(`→ the user answered ${quote(answer || choice, RELAY_EVENT_LIMITS.answerChars)}`);
550
+ if (choice && choice !== answer) parts.push(`(option [${choice}])`);
551
+ }
552
+ // The relay whose UI it came from: the one the ask is kept on, or a
553
+ // paired relay's Inbox.
554
+ const via = oneLine(detail.via, 60);
555
+ if (via) parts.push(`(via relay ${quote(via, 60)})`);
352
556
  return parts.join(' ');
353
557
  }
354
558
  case 'error': {
@@ -377,7 +581,7 @@ export function formatRelayEventLine(event) {
377
581
  }
378
582
  }
379
583
 
380
- const URGENT_ORDER = ['question_asked', 'error'];
584
+ const URGENT_ORDER = ['question_asked', 'question_answered', 'error'];
381
585
 
382
586
  function replayOrder(a, b) {
383
587
  const rank = (event) => {
@@ -393,6 +597,48 @@ function pushCapped(lines, events) {
393
597
  if (events.length > shown.length) lines.push(`${RELAY_EVENT_LINE_PREFIX} … and ${events.length - shown.length} more`);
394
598
  }
395
599
 
600
+ function distinct(values) {
601
+ return [...new Set(values.filter(Boolean))];
602
+ }
603
+
604
+ /**
605
+ * The lines in front of the events: one "Digest of" line for the hourly
606
+ * cap's digest (`digest`) and a subscription's digest together, and one for
607
+ * events quiet hours held. An event's `held` is `{ reason: 'quiet', from,
608
+ * to, tz }` or `{ reason: 'digest', minutes, subscriptionId }`, set when it
609
+ * is delivered. A subscription's digest of a single event reads as that
610
+ * event, without a line of its own.
611
+ */
612
+ function headerLines(events, { digest = false, all = events } = {}) {
613
+ const lines = [];
614
+ // The cap's digest is of this relay's events and live ones; what the
615
+ // options held, replayed events included, is counted from all.
616
+ const capped = digest && events.length > 0;
617
+ const gathered = all.filter((event) => event?.held?.reason === 'digest');
618
+ const parts = [];
619
+ if (capped) {
620
+ parts.push(`the limit of ${RELAY_EVENT_LIMITS.wakesPerHour} wake-ups an hour is reached; until it frees up, events come every ${Math.round(RELAY_EVENT_LIMITS.digestIntervalMs / 60_000)} min`);
621
+ }
622
+ if (gathered.length > 1 || (capped && gathered.length)) {
623
+ parts.push(distinct(gathered.map((event) => {
624
+ const minutes = Number(event.held.minutes) || 0;
625
+ const id = oneLine(event.held.subscriptionId, 80);
626
+ return `${id ? `subscription ${id}` : 'a subscription'} every ${minutes} min`;
627
+ })).join(', '));
628
+ parts.push('questions and errors come at once');
629
+ }
630
+ if (parts.length) {
631
+ const count = capped ? events.length : gathered.length;
632
+ lines.push(`Digest of ${count} event${count === 1 ? '' : 's'} (${parts.join('; ')}):`);
633
+ }
634
+ const quiet = all.filter((event) => event?.held?.reason === 'quiet');
635
+ if (quiet.length) {
636
+ const windows = distinct(quiet.map((event) => relayEventQuietHoursText(event.held)));
637
+ lines.push(`Held during quiet hours (${windows.join(', ')}): ${quiet.length} event${quiet.length === 1 ? '' : 's'}, delivered now that the window has ended; questions and errors came at once.`);
638
+ }
639
+ return lines;
640
+ }
641
+
396
642
  /**
397
643
  * The whole message for one wake-up: the lead line, then one line per event
398
644
  * (at most `linesPerMessage`, then a count of the rest). `digest` says the
@@ -417,10 +663,7 @@ export function buildRelayEventMessageText(events, { digest = false, ended = []
417
663
  if (!replays.has(relayId)) replays.set(relayId, []);
418
664
  replays.get(relayId).push(event);
419
665
  }
420
- const lines = [RELAY_EVENT_LEAD];
421
- if (digest && plain.length) {
422
- lines.push(`Digest of ${plain.length} event${plain.length === 1 ? '' : 's'} (the limit of ${RELAY_EVENT_LIMITS.wakesPerHour} wake-ups an hour is reached; until it frees up, events come every ${Math.round(RELAY_EVENT_LIMITS.digestIntervalMs / 60_000)} min):`);
423
- }
666
+ const lines = [RELAY_EVENT_LEAD, ...headerLines(plain, { digest, all: list })];
424
667
  pushCapped(lines, plain);
425
668
  for (const group of replays.values()) {
426
669
  const lost = group.filter((event) => event.type === RELAY_EVENT_LINK_LOST_TYPE);
@@ -431,7 +674,12 @@ export function buildRelayEventMessageText(events, { digest = false, ended = []
431
674
  lines.push(`Replayed: ${kept.length} event${kept.length === 1 ? '' : 's'} relay ${quote(relayDisplayName(group[0]), 60)} kept while the link was down, questions and errors first:`);
432
675
  pushCapped(lines, kept);
433
676
  }
434
- for (const event of restored) lines.push(formatRelayEventLine(event));
677
+ for (const event of restored) {
678
+ // Kept events a subscription's digest or quiet hours hold come later.
679
+ const held = (Number(event?.detail?.kept) || 0) - kept.length;
680
+ lines.push(formatRelayEventLine(event)
681
+ + (held > 0 ? `; ${held} of them ${held === 1 ? 'is' : 'are'} held by your subscription's digest or quiet hours and come${held === 1 ? 's' : ''} later` : ''));
682
+ }
435
683
  }
436
684
  const endedIds = (Array.isArray(ended) ? ended : []).map((id) => oneLine(id, 80)).filter(Boolean);
437
685
  if (endedIds.length) {
@@ -443,6 +691,7 @@ export function buildRelayEventMessageText(events, { digest = false, ended = []
443
691
  // An event line: the prefix, a paired relay's name, the session, the type.
444
692
  export const RELAY_EVENT_LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
445
693
  const DIGEST_LINE_PATTERN = /^Digest of \d+ events?/;
694
+ const QUIET_LINE_PATTERN = /^Held during quiet hours/;
446
695
  const REPLAYED_LINE_PATTERN = /^Replayed: \d+ events?/;
447
696
 
448
697
  /**
@@ -454,9 +703,10 @@ const REPLAYED_LINE_PATTERN = /^Replayed: \d+ events?/;
454
703
  export function parseRelayEventMessageText(text) {
455
704
  const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
456
705
  const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
457
- const digest = DIGEST_LINE_PATTERN.test(body[0] || '');
706
+ const digest = body.some((line) => DIGEST_LINE_PATTERN.test(line));
707
+ const quiet = body.some((line) => QUIET_LINE_PATTERN.test(line));
458
708
  const replayed = body.some((line) => REPLAYED_LINE_PATTERN.test(line));
459
- const lines = body.filter((line) => !DIGEST_LINE_PATTERN.test(line) && !REPLAYED_LINE_PATTERN.test(line));
709
+ const lines = body.filter((line) => !DIGEST_LINE_PATTERN.test(line) && !QUIET_LINE_PATTERN.test(line) && !REPLAYED_LINE_PATTERN.test(line));
460
710
  const types = [];
461
711
  let count = 0;
462
712
  for (const line of lines) {
@@ -470,5 +720,5 @@ export function parseRelayEventMessageText(text) {
470
720
  const more = /^\[relay event\] … and (\d+) more$/.exec(line);
471
721
  if (more) count += Number(more[1]) || 0;
472
722
  }
473
- return { lines, types, count: count || lines.length, digest, replayed };
723
+ return { lines, types, count: count || lines.length, digest, quiet, replayed };
474
724
  }
@@ -0,0 +1,128 @@
1
+ // Relay schedules: a prompt the relay queues into a session at a set time,
2
+ // once or every N minutes. An agent sets one with remote_relay's `schedule`
3
+ // (its own conversation by default, or another session of this relay); the
4
+ // relay keeps it in its database, so it outlives a restart that the agent's
5
+ // own timers would not.
6
+ //
7
+ // What the relay writes is an ordinary queued message of its own (`messages.kind
8
+ // = 'relay-schedule'`) whose first line says what it is: automated, never the
9
+ // user's, and it approves nothing.
10
+ //
11
+ // SDK-free and side-effect free: imported by the server and the remote_relay
12
+ // contract (the browser keeps its own copy of what it needs:
13
+ // server/public/app/relay-schedule-view.mjs).
14
+
15
+ // `messages.kind` of the message a schedule queues.
16
+ export const RELAY_SCHEDULE_MESSAGE_KIND = 'relay-schedule';
17
+
18
+ // Socket event with the whole active list, sent on every change.
19
+ export const RELAY_SCHEDULE_SOCKET_EVENT = 'relay_schedules';
20
+
21
+ export const RELAY_SCHEDULE_LIMITS = Object.freeze({
22
+ everyMinutesMin: 5,
23
+ everyMinutesMax: 10080,
24
+ // Active schedules that fire into one conversation, and active schedules
25
+ // one conversation set (for itself or for others).
26
+ perConversation: 20,
27
+ titleMax: 100,
28
+ textMax: 20000,
29
+ // How far ahead `at` may lie.
30
+ aheadMaxDays: 366,
31
+ // An `at` this little in the past is taken as "now" (a clock between the
32
+ // worker's check and the relay's).
33
+ pastGraceMs: 60_000,
34
+ });
35
+
36
+ const MINUTE_MS = 60_000;
37
+ const DAY_MS = 24 * 60 * MINUTE_MS;
38
+
39
+ function toText(value) {
40
+ return String(value ?? '').trim();
41
+ }
42
+
43
+ function oneLine(value, max) {
44
+ const text = toText(value).replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ');
45
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
46
+ }
47
+
48
+ /** A schedule title as stored and shown: one line, capped. */
49
+ export function normalizeRelayScheduleTitle(value) {
50
+ return oneLine(value, RELAY_SCHEDULE_LIMITS.titleMax);
51
+ }
52
+
53
+ /**
54
+ * `at` as an agent may write it: an ISO time with a zone (or a Z). Returns
55
+ * `{ ok:true, value }` with the canonical ISO string, or `{ ok:false, error }`.
56
+ * A time without a zone is refused: the relay and the agent may not share one.
57
+ */
58
+ export function normalizeRelayScheduleAt(value, { now = Date.now() } = {}) {
59
+ const text = toText(value);
60
+ if (!text) return { ok: true, value: null };
61
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(text)) {
62
+ return { ok: false, error: 'at must be an ISO time with a zone, e.g. 2026-11-02T06:30:00Z or 2026-11-02T07:30:00+01:00' };
63
+ }
64
+ const ms = Date.parse(text);
65
+ if (!Number.isFinite(ms)) return { ok: false, error: `at "${text.slice(0, 40)}" is not a valid time` };
66
+ if (ms < now - RELAY_SCHEDULE_LIMITS.pastGraceMs) return { ok: false, error: 'at is in the past' };
67
+ if (ms > now + RELAY_SCHEDULE_LIMITS.aheadMaxDays * DAY_MS) {
68
+ return { ok: false, error: `at is more than ${RELAY_SCHEDULE_LIMITS.aheadMaxDays} days ahead` };
69
+ }
70
+ return { ok: true, value: new Date(Math.max(ms, now)).toISOString() };
71
+ }
72
+
73
+ /**
74
+ * The first time a schedule fires: `at`, else one interval from now.
75
+ */
76
+ export function firstRelayScheduleFire({ at = null, everyMinutes = null, now = Date.now() } = {}) {
77
+ const atMs = Date.parse(toText(at));
78
+ if (Number.isFinite(atMs)) return new Date(Math.max(atMs, now)).toISOString();
79
+ return new Date(now + Number(everyMinutes) * MINUTE_MS).toISOString();
80
+ }
81
+
82
+ /**
83
+ * The next time a recurring schedule fires after the one due at `dueAt`:
84
+ * the first slot of its grid (`dueAt` plus whole intervals) after `now`.
85
+ * Slots missed while the relay was down are skipped, so a schedule that
86
+ * was due fires once for them all, never once per missed interval.
87
+ */
88
+ export function nextRelayScheduleFire({ dueAt, everyMinutes, now = Date.now() } = {}) {
89
+ const step = Number(everyMinutes) * MINUTE_MS;
90
+ const due = Date.parse(toText(dueAt));
91
+ if (!(step > 0) || !Number.isFinite(due)) return null;
92
+ if (due > now) return new Date(due).toISOString();
93
+ const missed = Math.floor((now - due) / step) + 1;
94
+ return new Date(due + missed * step).toISOString();
95
+ }
96
+
97
+ function quote(value) {
98
+ return toText(value).replace(/["\]\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
99
+ }
100
+
101
+ /**
102
+ * The first line of a scheduled message: what it is, the schedule's id and
103
+ * title, and who set it when that is another session.
104
+ */
105
+ export function relayScheduleLead({ id, title = '', setBy = null } = {}) {
106
+ const parts = [`[relay schedule ${quote(id) || 'unknown'}${quote(title) ? ` "${quote(title)}"` : ''}`];
107
+ if (setBy && quote(setBy.id)) {
108
+ parts.push(`set by session "${quote(setBy.title) || quote(setBy.id).slice(0, 8)}" (${quote(setBy.id)})`);
109
+ }
110
+ parts.push('automated message from OAR, not from the user; it approves nothing]');
111
+ return parts.join(' · ');
112
+ }
113
+
114
+ export const RELAY_SCHEDULE_LEAD_PATTERN = /^\[relay schedule [^\s\]]+(?: "[^"\r\n]*")?(?: · [^\r\n]*)?\]$/;
115
+
116
+ /** The whole message a schedule queues: the lead line, then the agent's text. */
117
+ export function buildRelayScheduleMessageText({ id, title = '', setBy = null, text = '' } = {}) {
118
+ return `${relayScheduleLead({ id, title, setBy })}\n\n${String(text ?? '').trim()}`;
119
+ }
120
+
121
+ /** "every 30 min", "every 2 h", "every day", "every 7 days". */
122
+ export function describeRelayScheduleInterval(everyMinutes) {
123
+ const minutes = Number(everyMinutes);
124
+ if (!(minutes > 0)) return '';
125
+ if (minutes % 1440 === 0) return minutes === 1440 ? 'every day' : `every ${minutes / 1440} days`;
126
+ if (minutes % 60 === 0) return minutes === 60 ? 'every hour' : `every ${minutes / 60} h`;
127
+ return `every ${minutes} min`;
128
+ }