@oar-sh/oar 0.9.15 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  5. package/server/claude-worker/claude-session-process.mjs +4 -4
  6. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  7. package/server/db-schema.mjs +52 -0
  8. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  9. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  10. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  11. package/server/migrations/0022-relay-schedules.mjs +142 -0
  12. package/server/migrations/0023-relay-asks.mjs +141 -0
  13. package/server/public/app/ask-user-view.js +3 -0
  14. package/server/public/app/bootstrap.js +61 -3
  15. package/server/public/app/context-usage-view.mjs +17 -0
  16. package/server/public/app/conversation-group-order.mjs +35 -0
  17. package/server/public/app/inbox-ui.js +356 -0
  18. package/server/public/app/inbox-view.mjs +370 -0
  19. package/server/public/app/journal-view.js +23 -2
  20. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  21. package/server/public/app/relay-event-view.mjs +33 -7
  22. package/server/public/app/relay-schedule-view.mjs +119 -0
  23. package/server/public/app/relay-schedules-ui.js +172 -0
  24. package/server/public/app/socket-handlers.js +27 -3
  25. package/server/public/app/store.js +18 -5
  26. package/server/public/index.html +159 -2
  27. package/server/relay-tools.md +18 -3
  28. package/server/repositories/conversation-group-repository.mjs +97 -11
  29. package/server/repositories/remote-relay-repository.mjs +20 -2
  30. package/server/repositories/session-repository.mjs +7 -1
  31. package/server/routes/ask-user-routes.mjs +57 -3
  32. package/server/routes/cache-routes.mjs +6 -0
  33. package/server/routes/conversation-group-routes.mjs +17 -2
  34. package/server/routes/messages-routes.mjs +3 -3
  35. package/server/routes/relay-ask-routes.mjs +387 -0
  36. package/server/routes/relay-event-routes.mjs +3 -1
  37. package/server/routes/relay-schedule-routes.mjs +57 -0
  38. package/server/routes/sessions-routes.mjs +568 -42
  39. package/server/server-runtime.mjs +80 -1
  40. package/server/services/cache-rebuild-service.mjs +19 -2
  41. package/server/services/conversation-preferences-service.mjs +17 -0
  42. package/server/services/relay-ask-link-service.mjs +174 -0
  43. package/server/services/relay-ask-service.mjs +604 -0
  44. package/server/services/relay-event-service.mjs +589 -73
  45. package/server/services/relay-schedule-service.mjs +596 -0
  46. package/server/services/remote-relay-caller-context.mjs +89 -20
  47. package/server/services/remote-relay-dispatcher.mjs +956 -30
  48. package/shared/relay-asks.mjs +145 -0
  49. package/shared/relay-events.mjs +268 -18
  50. package/shared/relay-schedules.mjs +128 -0
  51. package/shared/remote-relay-contract.mjs +553 -25
  52. package/shared/remote-relay-tool-core.mjs +16 -5
@@ -23,6 +23,30 @@
23
23
  * interval while its watched set has a turn or task running, none while it
24
24
  * is idle: the watcher's sign that its watching still works.
25
25
  *
26
+ * Options of a subscription (migrations/0019-relay-event-subscription-options.mjs),
27
+ * all applied where the watcher is (for a paired relay's sessions: on the
28
+ * mirror here):
29
+ *
30
+ * - `digest_minutes`: its non-urgent events are held and go as one row, N
31
+ * minutes after the last such row at the earliest.
32
+ * - `quiet_hours`: its non-urgent events that would go inside the window are
33
+ * held until it ends; no heartbeat is made inside it.
34
+ * - `exclude`: sessions an "all" subscription leaves out (the watched relay
35
+ * is told too, for all_idle, stalled and the heartbeat's list).
36
+ *
37
+ * question_asked and error are never held. A one-shot subscription's event
38
+ * keeps to its options after the subscription ended with it, and what a
39
+ * paired relay replays after a link gap keeps to them too. An event that several of the
40
+ * watcher's subscriptions took goes when the first of them lets it; the
41
+ * hourly cap stays the outer limit. Held events are undelivered rows like
42
+ * any, so they and their timers survive a restart (planDelivery).
43
+ *
44
+ * The watcher's own turns: a prompt it sent with send or create_session is
45
+ * noted (noteOwnPrompt). While it waits for the reply (beginOwnWait) that
46
+ * turn's turn_end is held; a wait that returns the finished reply
47
+ * (endOwnWait with `received`) drops it, and a turn_end that comes later is
48
+ * never recorded for it. Errors and questions of the turn still go.
49
+ *
26
50
  * `transferSubscriptions` moves every subscription of one watcher, with its
27
51
  * undelivered events, to another: a watcher is replaced without a moment
28
52
  * where events go nowhere.
@@ -64,6 +88,7 @@ import {
64
88
  RELAY_EVENT_LINK_LOST_TYPE,
65
89
  RELAY_EVENT_LINK_RESTORED_TYPE,
66
90
  RELAY_EVENT_MESSAGE_KIND,
91
+ RELAY_EVENT_QUESTION_ANSWERED_TYPE,
67
92
  RELAY_EVENT_RELAY_LEVEL_TYPES,
68
93
  RELAY_EVENT_RELAY_RESTARTED_TYPE,
69
94
  RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
@@ -77,7 +102,9 @@ import {
77
102
  isRelayEventType,
78
103
  isRelayLevelOnly,
79
104
  isUrgentRelayEventType,
105
+ normalizeRelayEventQuietHours,
80
106
  relayEventMatchText,
107
+ relayEventQuietWindowEnd,
81
108
  relayEventTypesTake,
82
109
  } from '../../shared/relay-events.mjs';
83
110
 
@@ -145,10 +172,19 @@ export const RELAY_EVENT_PRUNE_SQL = Object.freeze({
145
172
  DELETE FROM relay_event_subscriptions WHERE ended_at IS NOT NULL AND ended_at < ?
146
173
  `,
147
174
  });
175
+ // A turn_end held while the watcher waits for that turn's reply goes anyway
176
+ // this long after the wait should have ended (a wait that never reported).
177
+ const OWN_WAIT_GRACE_MS = 60_000;
178
+ // The watcher's own prompts are kept a day: longer than any digest or quiet
179
+ // window holds their turn_end.
180
+ export const RELAY_EVENT_OWN_PROMPT_KEEP_MS = 25 * HOUR_MS;
148
181
  // A watched set must look idle twice, this far apart, before all_idle fires:
149
182
  // a task's end and the continuation turn it opens are seconds apart.
150
183
  const ALL_IDLE_SETTLE_MS = 10_000;
151
184
  const LOG_PREFIX = '[relay-events]';
185
+ // The `subscription_id` of an answer told to the conversations that asked:
186
+ // no subscription stands behind it (noteQuestionAnswered).
187
+ const ASK_DELIVERY_PREFIX = 'ask:';
152
188
 
153
189
  function toText(value) {
154
190
  return String(value ?? '').trim();
@@ -261,7 +297,16 @@ export function createRelayEventService({
261
297
  `),
262
298
  listEventsOfMessage: db.prepare(`SELECT * FROM relay_events WHERE message_id = ? ORDER BY id ASC`),
263
299
  markDelivered: db.prepare(`UPDATE relay_events SET delivered_at = ?, message_id = ? WHERE id = ?`),
300
+ setEventPayload: db.prepare(`UPDATE relay_events SET payload_json = ? WHERE id = ?`),
301
+ deleteEvent: db.prepare(`DELETE FROM relay_events WHERE id = ? AND delivered_at IS NULL`),
264
302
  dropUndelivered: db.prepare(`DELETE FROM relay_events WHERE watcher_conversation_id = ? AND delivered_at IS NULL`),
303
+ // An answer to the watcher's own ask is owed without a subscription.
304
+ dropUndeliveredUnowed: db.prepare(`
305
+ DELETE FROM relay_events WHERE watcher_conversation_id = ? AND delivered_at IS NULL
306
+ AND subscription_id NOT IN (SELECT id FROM relay_event_subscriptions WHERE end_reason = 'fired')
307
+ AND json_extract(payload_json, '$.ends') IS NULL
308
+ AND substr(subscription_id, 1, ${ASK_DELIVERY_PREFIX.length}) <> '${ASK_DELIVERY_PREFIX}'
309
+ `),
265
310
  // Wake-ups: the event messages that carried more than a heartbeat.
266
311
  countWakes: db.prepare(`
267
312
  SELECT COUNT(DISTINCT message_id) AS n FROM relay_events
@@ -321,6 +366,23 @@ export function createRelayEventService({
321
366
  `),
322
367
  take: db.prepare(`DELETE FROM app_settings WHERE key = ?`),
323
368
  } : null;
369
+ // Migration 0019: without it a subscription takes no options and the
370
+ // watcher's own replies are delivered as before.
371
+ const optionsAvailable = available && db
372
+ .prepare(`PRAGMA table_info(relay_event_subscriptions)`).all().some((column) => column.name === 'quiet_hours_json');
373
+ const optionSql = optionsAvailable ? {
374
+ setOptions: db.prepare(`UPDATE relay_event_subscriptions SET digest_minutes = ?, quiet_hours_json = ?, exclude_json = ? WHERE id = ?`),
375
+ markDigestDelivered: db.prepare(`UPDATE relay_event_subscriptions SET digest_last_at = ? WHERE id = ?`),
376
+ } : null;
377
+ const ownSql = optionsAvailable && tableExists('relay_event_own_prompts') ? {
378
+ insert: db.prepare(`
379
+ INSERT OR IGNORE INTO relay_event_own_prompts (message_id, watcher_conversation_id, relay_id, session_id, created_at)
380
+ VALUES (?, ?, ?, ?, ?)
381
+ `),
382
+ get: db.prepare(`SELECT * FROM relay_event_own_prompts WHERE message_id = ?`),
383
+ markReceived: db.prepare(`UPDATE relay_event_own_prompts SET received_at = ? WHERE message_id = ? AND watcher_conversation_id = ?`),
384
+ pruneBefore: db.prepare(`DELETE FROM relay_event_own_prompts WHERE created_at < ?`),
385
+ } : null;
324
386
  // Without migration 0012 the table has no remote_sync column.
325
387
  const deleteEndedSubscriptionsBefore = available
326
388
  ? db.prepare(crossAvailable ? RELAY_EVENT_PRUNE_SQL.deleteEndedSubscriptionsBefore : RELAY_EVENT_PRUNE_SQL.deleteEndedSubscriptionsBeforeNoSync)
@@ -406,6 +468,11 @@ export function createRelayEventService({
406
468
  const idleSinceBySubscription = new Map();
407
469
  // heartbeat: subscription id → since when its watched set has been busy
408
470
  const busySinceBySubscription = new Map();
471
+ // `${watcher}\n${messageId}` → { count, until }: the watcher waits for
472
+ // the reply to its own prompt; that turn's turn_end is held meanwhile.
473
+ const ownWaits = new Map();
474
+ // quiet_hours_json → the checked window (or null)
475
+ const quietCache = new Map();
409
476
  let allIdleTimer = null;
410
477
  let sweepTimer = null;
411
478
  let started = false;
@@ -448,8 +515,38 @@ export function createRelayEventService({
448
515
  return effectiveRelayEventTypes(parseJson(row?.events_json, []));
449
516
  }
450
517
 
518
+ function quietOf(row) {
519
+ const text = toText(row?.quiet_hours_json);
520
+ if (!text) return null;
521
+ if (!quietCache.has(text)) quietCache.set(text, normalizeRelayEventQuietHours(parseJson(text, null)).value || null);
522
+ return quietCache.get(text);
523
+ }
524
+
525
+ /** A subscription's options: `{ digest, quiet, exclude }` (0, null, [] when off). */
526
+ function optionsOf(row) {
527
+ const digest = Number(row?.digest_minutes) > 0 ? Math.trunc(Number(row.digest_minutes)) : 0;
528
+ const exclude = toText(row?.scope) === 'all' ? parseJson(row?.exclude_json, []) : [];
529
+ return {
530
+ digest,
531
+ quiet: quietOf(row),
532
+ exclude: (Array.isArray(exclude) ? exclude : []).map(toText).filter(Boolean),
533
+ };
534
+ }
535
+
536
+ function excludes(row, sessionId) {
537
+ const id = toText(sessionId);
538
+ return !!id && optionsOf(row).exclude.includes(id);
539
+ }
540
+
541
+ /** Whether `at` falls in the subscription's quiet window. */
542
+ function inQuietHours(row, at = now()) {
543
+ const quiet = quietOf(row);
544
+ return !!quiet && relayEventQuietWindowEnd(quiet, at) !== null;
545
+ }
546
+
451
547
  function shapeSubscription(row) {
452
548
  if (!row) return null;
549
+ const options = optionsOf(row);
453
550
  const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
454
551
  const sessionIds = scope === 'all' ? [] : parseJson(row.session_ids_json, []).map(toText).filter(Boolean);
455
552
  const mirror = isMirror(row);
@@ -484,6 +581,9 @@ export function createRelayEventService({
484
581
  ...(toText(row.match) ? { match: row.match } : {}),
485
582
  stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
486
583
  ...(Number(row.heartbeat_minutes) > 0 ? { heartbeatMinutes: Number(row.heartbeat_minutes) } : {}),
584
+ ...(options.digest ? { digestMinutes: options.digest } : {}),
585
+ ...(options.quiet ? { quietHours: options.quiet } : {}),
586
+ ...(options.exclude.length ? { exclude: options.exclude } : {}),
487
587
  createdAt: row.created_at,
488
588
  ...(row.ended_at ? { endedAt: row.ended_at, endReason: row.end_reason || null } : {}),
489
589
  firedCount: Number(row.fired_count || 0),
@@ -523,23 +623,115 @@ export function createRelayEventService({
523
623
  }
524
624
  }
525
625
 
526
- /** A watcher's subscriptions: the active ones and those that ended within the hour. */
626
+ /**
627
+ * A watcher's subscriptions: the active ones and those that ended within
628
+ * the hour. An active one says how many of its events its digest or quiet
629
+ * window holds (`heldCount`) and when the first of them is due
630
+ * (`nextDeliveryAt`, null when none is held).
631
+ */
527
632
  function listSubscriptions({ watcherConversationId, includeEndedSinceMs = HOUR_MS } = {}) {
528
633
  if (!available) return [];
634
+ const watcher = toText(watcherConversationId);
529
635
  const since = new Date(now() - includeEndedSinceMs).toISOString();
530
- return sql.listForWatcher.all(toText(watcherConversationId), since).map(shapeSubscription);
636
+ const held = heldBySubscription(watcher);
637
+ return sql.listForWatcher.all(watcher, since).map((row) => {
638
+ const shaped = shapeSubscription(row);
639
+ const mine = held.get(row.id);
640
+ // An ended one says so only while its last event (a one-shot's) is held.
641
+ if (shaped.endedAt && !mine) return shaped;
642
+ return { ...shaped, heldCount: mine?.count || 0, nextDeliveryAt: mine ? new Date(mine.next).toISOString() : null };
643
+ });
644
+ }
645
+
646
+ /** subscription id → `{ count, next }` of the watcher's events its options hold now. */
647
+ function heldBySubscription(watcher) {
648
+ const held = new Map();
649
+ if (!watcher || isServedKey(watcher)) return held;
650
+ let rows = [];
651
+ try {
652
+ rows = sql.listUndelivered.all(watcher);
653
+ } catch {
654
+ return held;
655
+ }
656
+ const at = now();
657
+ for (const entry of planDelivery(watcher, rows)) {
658
+ if (!entry.subscriptionId || entry.releaseAt <= at) continue;
659
+ const current = held.get(entry.subscriptionId) || { count: 0, next: Infinity };
660
+ current.count += 1;
661
+ current.next = Math.min(current.next, entry.releaseAt);
662
+ held.set(entry.subscriptionId, current);
663
+ }
664
+ return held;
531
665
  }
532
666
 
533
667
  // ─── Subscribe / unsubscribe ───────────────────────────────────────────────
534
668
 
669
+ /**
670
+ * A subscription's options, checked: `{ digest, quiet, exclude }` (null,
671
+ * null, [] when not asked for). `exclude` goes with "all" only. Throws
672
+ * RelayEventError (`invalid`, `unavailable` without migration 0019).
673
+ */
674
+ function prepareOptions({ all, digestMinutes = null, quietHours = null, exclude = null }) {
675
+ let digest = null;
676
+ if (digestMinutes !== null && digestMinutes !== undefined && digestMinutes !== '') {
677
+ const number = Number(digestMinutes);
678
+ if (!Number.isFinite(number)) throw new RelayEventError('invalid', 'digest_minutes must be a number of minutes');
679
+ digest = Math.min(RELAY_EVENT_LIMITS.digestMinutesMax, Math.max(RELAY_EVENT_LIMITS.digestMinutesMin, Math.trunc(number)));
680
+ }
681
+ const quiet = normalizeRelayEventQuietHours(quietHours);
682
+ if (!quiet.ok) throw new RelayEventError('invalid', quiet.error);
683
+ const excluded = [...new Set((Array.isArray(exclude) ? exclude : []).map(toText).filter(Boolean))].map((id) => id.slice(0, 200));
684
+ if (excluded.length && !all) {
685
+ throw new RelayEventError('invalid', 'exclude goes with sessions ["all"] only; a list of sessions names what it watches.');
686
+ }
687
+ if (excluded.length > RELAY_EVENT_LIMITS.excludeMax) {
688
+ throw new RelayEventError('invalid', `exclude takes at most ${RELAY_EVENT_LIMITS.excludeMax} sessions.`);
689
+ }
690
+ if ((digest !== null || quiet.value || excluded.length) && !optionsAvailable) {
691
+ throw new RelayEventError('unavailable', 'digest_minutes, quiet_hours and exclude are not available on this relay right now (its storage is missing; see the server log).');
692
+ }
693
+ return { digest, quiet: quiet.value, exclude: excluded };
694
+ }
695
+
696
+ function optionsLogText(options) {
697
+ const parts = [
698
+ options?.digest ? `digest ${options.digest} min` : '',
699
+ options?.quiet ? `quiet ${options.quiet.from}-${options.quiet.to} ${options.quiet.tz}` : '',
700
+ options?.exclude?.length ? `${options.exclude.length} excluded` : '',
701
+ ].filter(Boolean);
702
+ return parts.length ? ` [${parts.join(', ')}]` : '';
703
+ }
704
+
705
+ function storeOptions(id, options) {
706
+ if (!optionSql || !options) return;
707
+ optionSql.setOptions.run(
708
+ options.digest ?? null,
709
+ options.quiet ? JSON.stringify(options.quiet) : null,
710
+ options.exclude?.length ? JSON.stringify(options.exclude) : null,
711
+ id,
712
+ );
713
+ }
714
+
535
715
  /**
536
716
  * What a subscribe asks for, checked: `{ all, ids, types, matchText,
537
- * stalled, heartbeat }`. Throws RelayEventError (`invalid`, `notFound`).
538
- * `exclude` is the watcher, never watched by itself. `relayLevel` allows
539
- * relay_restarted (this relay's own watchers only); a subscription that
540
- * names nothing else needs no sessions.
717
+ * stalled, heartbeat, options }`. Throws RelayEventError (`invalid`,
718
+ * `notFound`). `ownId` is the watcher, never watched by itself.
719
+ * `relayLevel` allows relay_restarted (this relay's own watchers only); a
720
+ * subscription that names nothing else needs no sessions.
541
721
  */
542
- function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '', relayLevel = false }) {
722
+ function prepareSubscription({
723
+ scope,
724
+ sessionIds,
725
+ events,
726
+ match,
727
+ stalledMinutes,
728
+ heartbeatMinutes,
729
+ digestMinutes = null,
730
+ quietHours = null,
731
+ exclude = null,
732
+ ownId = '',
733
+ relayLevel = false,
734
+ }) {
543
735
  const all = scope === 'all';
544
736
  const ids = all ? [] : [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map(toText).filter(Boolean))];
545
737
  const named = Array.isArray(events) ? events.map(toText).filter(Boolean) : [];
@@ -557,9 +749,10 @@ export function createRelayEventService({
557
749
  if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
558
750
  throw new RelayEventError('invalid', `subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use "all" for more.`);
559
751
  }
560
- if (exclude && ids.includes(exclude)) {
561
- throw new RelayEventError('ownSession', `Session ${exclude} is the conversation you are running in: it is never watched, events of your own turns would wake you up.`);
752
+ if (ownId && ids.includes(ownId)) {
753
+ throw new RelayEventError('ownSession', `Session ${ownId} is the conversation you are running in: it is never watched, events of your own turns would wake you up.`);
562
754
  }
755
+ const options = prepareOptions({ all, digestMinutes, quietHours, exclude });
563
756
  const missing = ids.filter((id) => !isLive(conversation(id)));
564
757
  if (missing.length) {
565
758
  throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
@@ -581,7 +774,7 @@ export function createRelayEventService({
581
774
  ? null
582
775
  : Math.min(RELAY_EVENT_LIMITS.heartbeatMinutesMax, Math.max(RELAY_EVENT_LIMITS.heartbeatMinutesMin, Math.trunc(Number(heartbeatMinutes))));
583
776
  if (heartbeat !== null && !Number.isFinite(heartbeat)) throw new RelayEventError('invalid', 'heartbeat_minutes must be a number of minutes');
584
- return { all, ids, types, matchText: toText(match) ? String(match) : null, stalled, heartbeat };
777
+ return { all, ids, types, matchText: toText(match) ? String(match) : null, stalled, heartbeat, options };
585
778
  }
586
779
 
587
780
  /**
@@ -599,34 +792,42 @@ export function createRelayEventService({
599
792
  match = '',
600
793
  stalledMinutes = null,
601
794
  heartbeatMinutes = null,
795
+ digestMinutes = null,
796
+ quietHours = null,
797
+ exclude = null,
602
798
  } = {}) {
603
799
  if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
604
800
  const watcher = toText(watcherConversationId);
605
801
  if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
606
- const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher, relayLevel: true });
802
+ const prepared = prepareSubscription({
803
+ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, digestMinutes, quietHours, exclude, ownId: watcher, relayLevel: true,
804
+ });
607
805
  const { all, ids, types } = prepared;
608
806
  checkWatcherCapacity(watcher);
609
807
  const id = `sub_${randomBytes(5).toString('hex')}`;
610
808
  const at = isoNow();
611
- sql.insertSubscription.run(
612
- id,
613
- watcher,
614
- RELAY_EVENT_SELF_RELAY_ID,
615
- all ? 'all' : 'sessions',
616
- all ? null : JSON.stringify(ids),
617
- JSON.stringify(types),
618
- once ? 1 : 0,
619
- prepared.matchText,
620
- prepared.stalled,
621
- prepared.heartbeat,
622
- at,
623
- );
809
+ db.transaction(() => {
810
+ sql.insertSubscription.run(
811
+ id,
812
+ watcher,
813
+ RELAY_EVENT_SELF_RELAY_ID,
814
+ all ? 'all' : 'sessions',
815
+ all ? null : JSON.stringify(ids),
816
+ JSON.stringify(types),
817
+ once ? 1 : 0,
818
+ prepared.matchText,
819
+ prepared.stalled,
820
+ prepared.heartbeat,
821
+ at,
822
+ );
823
+ storeOptions(id, prepared.options);
824
+ })();
624
825
  const subscription = shapeSubscription(sql.getSubscription.get(id));
625
826
  // The watched set starts from what it is doing now: all_idle fires when
626
827
  // work that is running now (or starts later) has ended.
627
828
  noteBusy(sql.getSubscription.get(id));
628
829
  const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'this relay');
629
- log(`conv=${shortId(watcher)} subscribed ${id} to ${what} (${types.join(',')})${once ? ' once' : ''}`);
830
+ log(`conv=${shortId(watcher)} subscribed ${id} to ${what} (${types.join(',')})${once ? ' once' : ''}${optionsLogText(prepared.options)}`);
630
831
  broadcast();
631
832
  return subscription;
632
833
  }
@@ -790,10 +991,13 @@ export function createRelayEventService({
790
991
  // A paired relay's events wait for its fetch (and the retention bound):
791
992
  // its subscription's last events and its end must still reach it.
792
993
  if (stillWatching.has(watcher) || isServedKey(watcher)) continue;
793
- try { sql.dropUndelivered.run(watcher); } catch {}
994
+ // The event of a one-shot subscription that fired is still owed: it
995
+ // may be held by that subscription's options.
996
+ try { sql.dropUndeliveredUnowed.run(watcher); } catch {}
794
997
  const pending = flushTimers.get(watcher);
795
998
  if (pending) clearTimer(pending.timer);
796
999
  flushTimers.delete(watcher);
1000
+ scheduleFlush(watcher);
797
1001
  }
798
1002
  }
799
1003
 
@@ -833,7 +1037,7 @@ export function createRelayEventService({
833
1037
 
834
1038
  function subscriptionCovers(row, sessionId) {
835
1039
  if (row.watcher_conversation_id === sessionId) return false;
836
- if (toText(row.scope) === 'all') return true;
1040
+ if (toText(row.scope) === 'all') return !excludes(row, sessionId);
837
1041
  return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
838
1042
  }
839
1043
 
@@ -903,6 +1107,12 @@ export function createRelayEventService({
903
1107
  const ending = heartbeat ? [] : matches
904
1108
  .filter(({ row, hit }) => Number(row.once || 0) === 1 && (!toText(row.match) || hit))
905
1109
  .map(({ row }) => row.id);
1110
+ // The watcher already has this reply: it waited for it itself. One
1111
+ // that ends a one-shot subscription still goes, or it would never end.
1112
+ if (!ending.length && ownReplyReceived(watcher, event)) {
1113
+ log(`conv=${shortId(watcher)} turn_end of ${shortId(sessionId)} not recorded: the watcher received that reply itself`);
1114
+ continue;
1115
+ }
906
1116
  const payload = { ...stored, subscriptionIds: matches.map(({ row }) => row.id), ...(ending.length ? { ends: ending } : {}) };
907
1117
  sql.insertEvent.run(first.id, watcher, RELAY_EVENT_SELF_RELAY_ID, sessionId || '', event.type, urgent ? 1 : 0, JSON.stringify(payload), event.at);
908
1118
  if (!heartbeat) for (const { row } of matches) sql.markFired.run(event.at, row.id);
@@ -940,7 +1150,107 @@ export function createRelayEventService({
940
1150
  return Math.max(now(), last + RELAY_EVENT_LIMITS.digestIntervalMs);
941
1151
  }
942
1152
 
943
- function dueAtFor(watcher, undelivered) {
1153
+ // ─── Held events: options and the watcher's own waits ─────────────────────
1154
+
1155
+ function ownWaitKey(watcher, messageId) {
1156
+ return `${watcher}\n${messageId}`;
1157
+ }
1158
+
1159
+ /** Until when a turn_end waits for the watcher's own wait on its reply, or 0. */
1160
+ function ownWaitHold(watcher, row, payload) {
1161
+ if (toText(row.type) !== 'turn_end') return 0;
1162
+ const messageId = toText(payload?.detail?.messageId);
1163
+ if (!messageId) return 0;
1164
+ const wait = ownWaits.get(ownWaitKey(watcher, messageId));
1165
+ return wait && wait.until > now() ? wait.until : 0;
1166
+ }
1167
+
1168
+ /** Whether a turn_end is of the watcher's own prompt whose finished reply it received. */
1169
+ function ownReplyReceived(watcher, event) {
1170
+ if (!ownSql || toText(event?.type) !== 'turn_end') return false;
1171
+ const messageId = toText(event?.detail?.messageId);
1172
+ if (!messageId) return false;
1173
+ try {
1174
+ const own = ownSql.get.get(messageId);
1175
+ return !!own && own.watcher_conversation_id === watcher && !!own.received_at;
1176
+ } catch {
1177
+ return false;
1178
+ }
1179
+ }
1180
+
1181
+ /**
1182
+ * When one subscription lets a non-urgent event go: at once without
1183
+ * options; with a digest, digest_minutes after its last digest row (or its
1184
+ * start); a time inside the quiet window moves to the window's end.
1185
+ */
1186
+ function optionRelease(sub, row) {
1187
+ const open = { releaseAt: 0, held: null, subscriptionId: null, digestSubscriptionId: null };
1188
+ if (!sub) return open;
1189
+ const { digest, quiet } = optionsOf(sub);
1190
+ if (!digest && !quiet) return open;
1191
+ let releaseAt = Date.parse(String(row.created_at)) || now();
1192
+ let held = null;
1193
+ if (digest) {
1194
+ const last = Date.parse(String(sub.digest_last_at || '')) || Date.parse(String(sub.created_at || '')) || 0;
1195
+ releaseAt = Math.max(releaseAt, last + digest * 60_000);
1196
+ held = { reason: 'digest', minutes: digest, subscriptionId: sub.id };
1197
+ }
1198
+ if (quiet) {
1199
+ const end = relayEventQuietWindowEnd(quiet, releaseAt);
1200
+ if (end !== null) {
1201
+ releaseAt = end;
1202
+ held = { reason: 'quiet', ...quiet };
1203
+ }
1204
+ }
1205
+ return { releaseAt, held, subscriptionId: sub.id, digestSubscriptionId: digest ? sub.id : null };
1206
+ }
1207
+
1208
+ /**
1209
+ * When each undelivered event of a watcher may go: `{ row, payload,
1210
+ * releaseAt, held, subscriptionId, digestSubscriptionId }`, releaseAt 0
1211
+ * for at once. Urgent events and heartbeats are never held; a turn_end the
1212
+ * watcher is waiting for itself waits for that wait; the others go when
1213
+ * the first of the subscriptions that took them lets them (one without
1214
+ * options at once). Rebuilt from the rows and the subscriptions each
1215
+ * time, so a restart changes nothing.
1216
+ */
1217
+ function planDelivery(watcher, rows) {
1218
+ const subscriptions = new Map(activeRows().filter((row) => row.watcher_conversation_id === watcher).map((row) => [row.id, row]));
1219
+ // A one-shot subscription ends as its event is recorded: that event
1220
+ // still keeps to its options.
1221
+ const subscriptionOf = (id) => {
1222
+ if (subscriptions.has(id)) return subscriptions.get(id);
1223
+ let row = null;
1224
+ try {
1225
+ row = sql.getSubscription.get(id) || null;
1226
+ } catch {}
1227
+ const fired = row && row.watcher_conversation_id === watcher && toText(row.end_reason) === 'fired' ? row : null;
1228
+ subscriptions.set(id, fired);
1229
+ return fired;
1230
+ };
1231
+ return rows.map((row) => {
1232
+ const payload = parseJson(row.payload_json, null);
1233
+ const open = { row, payload, releaseAt: 0, held: null, subscriptionId: null, digestSubscriptionId: null };
1234
+ if (Number(row.urgent) === 1 || isHeartbeatRow(row)) return open;
1235
+ const waitUntil = ownWaitHold(watcher, row, payload);
1236
+ if (waitUntil) return { ...open, releaseAt: waitUntil };
1237
+ const named = Array.isArray(payload?.subscriptionIds) && payload.subscriptionIds.length
1238
+ ? payload.subscriptionIds.map(toText)
1239
+ : [toText(row.subscription_id)];
1240
+ // A subscription that ended otherwise (unsubscribed) has no say any
1241
+ // more; with none left that has, the event goes as usual.
1242
+ let best = null;
1243
+ for (const id of named) {
1244
+ const subscription = subscriptionOf(id);
1245
+ if (!subscription) continue;
1246
+ const option = optionRelease(subscription, row);
1247
+ if (!best || option.releaseAt < best.releaseAt) best = option;
1248
+ }
1249
+ return best ? { ...open, ...best } : open;
1250
+ });
1251
+ }
1252
+
1253
+ function readyDue(watcher, undelivered) {
944
1254
  if (undelivered.some((row) => Number(row.urgent) === 1)) return now();
945
1255
  if (overCap(watcher)) {
946
1256
  // Heartbeats are not held for the digest: they go after the usual
@@ -955,6 +1265,15 @@ export function createRelayEventService({
955
1265
  return coalescedDue(undelivered);
956
1266
  }
957
1267
 
1268
+ /** The next flush: what is ready by the usual rules, else the first held event's time. */
1269
+ function dueAtFor(watcher, plan) {
1270
+ const at = now();
1271
+ const ready = plan.filter((entry) => entry.releaseAt <= at).map((entry) => entry.row);
1272
+ const dues = plan.filter((entry) => entry.releaseAt > at).map((entry) => entry.releaseAt);
1273
+ if (ready.length) dues.push(readyDue(watcher, ready));
1274
+ return Math.min(...dues);
1275
+ }
1276
+
958
1277
  function scheduleFlush(watcher, { urgent = false } = {}) {
959
1278
  // A paired relay's events wait for its fetch.
960
1279
  if (isServedKey(watcher)) return;
@@ -965,7 +1284,7 @@ export function createRelayEventService({
965
1284
  return;
966
1285
  }
967
1286
  if (!undelivered.length) return;
968
- const dueAt = urgent ? now() : dueAtFor(watcher, undelivered);
1287
+ const dueAt = urgent ? now() : dueAtFor(watcher, planDelivery(watcher, undelivered));
969
1288
  const current = flushTimers.get(watcher);
970
1289
  if (current && current.dueAt <= dueAt) return;
971
1290
  if (current) clearTimer(current.timer);
@@ -982,19 +1301,26 @@ export function createRelayEventService({
982
1301
  }
983
1302
 
984
1303
  /**
985
- * Writes the watcher's undelivered events into its conversation now:
986
- * appended to its event row that is still pending, else as a new message.
987
- * Returns `{ messageId, merged, digest, count }` or null.
1304
+ * Writes the watcher's undelivered events that are not held into its
1305
+ * conversation now: appended to its event row that is still pending, else
1306
+ * as a new message. Returns `{ messageId, merged, digest, count }` or null.
988
1307
  */
989
1308
  function flush(watcher) {
990
1309
  if (!available || isServedKey(watcher)) return null;
991
- let rows = sql.listUndelivered.all(watcher);
992
- if (!rows.length) return null;
1310
+ const undelivered = sql.listUndelivered.all(watcher);
1311
+ if (!undelivered.length) return null;
993
1312
  const target = conversation(watcher);
994
1313
  if (!isLive(target)) {
995
1314
  sql.dropUndelivered.run(watcher);
996
1315
  return null;
997
1316
  }
1317
+ const readyAt = now();
1318
+ const plan = new Map(planDelivery(watcher, undelivered).filter((entry) => entry.releaseAt <= readyAt).map((entry) => [entry.row.id, entry]));
1319
+ let rows = undelivered.filter((row) => plan.has(row.id));
1320
+ if (!rows.length) {
1321
+ scheduleFlush(watcher);
1322
+ return null;
1323
+ }
998
1324
  const urgent = rows.some((row) => Number(row.urgent) === 1);
999
1325
  const capped = overCap(watcher);
1000
1326
  let digest = false;
@@ -1012,11 +1338,22 @@ export function createRelayEventService({
1012
1338
  digest = rows.some((row) => !isHeartbeatRow(row));
1013
1339
  }
1014
1340
  }
1015
- const events = rows.map((row) => parseJson(row.payload_json, null)).filter(Boolean);
1341
+ // An event a subscription's options held says so in its line's message;
1342
+ // the mark is stored, for the text rebuilt when more is appended.
1343
+ const marked = rows.map((row) => {
1344
+ const { payload, held } = plan.get(row.id);
1345
+ return { row, payload: payload && held ? { ...payload, held } : payload, held };
1346
+ });
1347
+ const events = marked.map((entry) => entry.payload).filter(Boolean);
1016
1348
  const ended = [...new Set(events.flatMap((event) => (Array.isArray(event.ends) ? event.ends : [])))];
1349
+ const digestSubscriptions = [...new Set(rows.map((row) => plan.get(row.id).digestSubscriptionId).filter(Boolean))];
1017
1350
  const at = isoNow();
1018
1351
  let result = null;
1019
1352
  const write = db.transaction(() => {
1353
+ for (const entry of marked) {
1354
+ if (entry.held && entry.payload) sql.setEventPayload.run(JSON.stringify(entry.payload), entry.row.id);
1355
+ }
1356
+ for (const id of digestSubscriptions) optionSql?.markDigestDelivered.run(at, id);
1020
1357
  const pending = sql.findPendingEventRow.get(watcher, RELAY_EVENT_MESSAGE_KIND) || null;
1021
1358
  if (pending) {
1022
1359
  const earlier = sql.listEventsOfMessage.all(pending.id).map((row) => parseJson(row.payload_json, null)).filter(Boolean);
@@ -1148,13 +1485,17 @@ export function createRelayEventService({
1148
1485
  match = '',
1149
1486
  stalledMinutes = null,
1150
1487
  heartbeatMinutes = null,
1488
+ exclude = null,
1151
1489
  dryRun = false,
1152
1490
  } = {}) {
1153
1491
  if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
1154
1492
  requireCross();
1155
1493
  const subscriber = subscriberOf(subscriberRelayId);
1156
1494
  const watcher = toText(watcherId).slice(0, 200);
1157
- const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes });
1495
+ // Digest and quiet hours are the watching relay's to apply; the sessions
1496
+ // an "all" subscription leaves out matter here too (all_idle, stalled,
1497
+ // the heartbeat's list).
1498
+ const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude });
1158
1499
  const { all, ids, types } = prepared;
1159
1500
  if (Number(remoteSql.countServedOfWatcher.get(subscriber, watcher)?.n || 0) >= RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher) {
1160
1501
  throw new RelayEventError('limit', `That conversation already has ${RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher} subscriptions on this relay; unsubscribe one first.`);
@@ -1172,28 +1513,32 @@ export function createRelayEventService({
1172
1513
  ...(prepared.matchText ? { match: prepared.matchText } : {}),
1173
1514
  stalledMinutes: prepared.stalled,
1174
1515
  ...(prepared.heartbeat ? { heartbeatMinutes: prepared.heartbeat } : {}),
1516
+ ...(prepared.options.exclude.length ? { exclude: prepared.options.exclude } : {}),
1175
1517
  };
1176
1518
  }
1177
1519
  const id = `sub_${randomBytes(5).toString('hex')}`;
1178
1520
  const at = isoNow();
1179
- remoteSql.insertServed.run(
1180
- id,
1181
- servedWatcherKey(subscriber),
1182
- RELAY_EVENT_SELF_RELAY_ID,
1183
- all ? 'all' : 'sessions',
1184
- all ? null : JSON.stringify(ids),
1185
- JSON.stringify(types),
1186
- once ? 1 : 0,
1187
- prepared.matchText,
1188
- prepared.stalled,
1189
- prepared.heartbeat,
1190
- at,
1191
- subscriber,
1192
- toText(subscriberRelayName).slice(0, 60) || null,
1193
- watcher || null,
1194
- toText(watcherTitle).slice(0, 200) || null,
1195
- at,
1196
- );
1521
+ db.transaction(() => {
1522
+ remoteSql.insertServed.run(
1523
+ id,
1524
+ servedWatcherKey(subscriber),
1525
+ RELAY_EVENT_SELF_RELAY_ID,
1526
+ all ? 'all' : 'sessions',
1527
+ all ? null : JSON.stringify(ids),
1528
+ JSON.stringify(types),
1529
+ once ? 1 : 0,
1530
+ prepared.matchText,
1531
+ prepared.stalled,
1532
+ prepared.heartbeat,
1533
+ at,
1534
+ subscriber,
1535
+ toText(subscriberRelayName).slice(0, 60) || null,
1536
+ watcher || null,
1537
+ toText(watcherTitle).slice(0, 200) || null,
1538
+ at,
1539
+ );
1540
+ storeOptions(id, { exclude: prepared.options.exclude });
1541
+ })();
1197
1542
  const row = sql.getSubscription.get(id);
1198
1543
  noteBusy(row);
1199
1544
  log(`relay ${shortId(subscriber)} subscribed ${id} to ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${types.join(',')})${once ? ' once' : ''}`);
@@ -1323,9 +1668,12 @@ export function createRelayEventService({
1323
1668
  /**
1324
1669
  * Stores this relay's side of a subscription a paired relay accepted:
1325
1670
  * `remote` is that relay's subscription (id, scope, sessions with titles,
1326
- * events, …). Returns the subscription as list_subscriptions shows it.
1671
+ * events, …). `digestMinutes`, `quietHours` and `exclude` are applied
1672
+ * here, where the events are delivered (exclude also to what that relay
1673
+ * sends, should it not know the option). Returns the subscription as
1674
+ * list_subscriptions shows it.
1327
1675
  */
1328
- function addMirror({ watcherConversationId, relayId, relayName = '', remote } = {}) {
1676
+ function addMirror({ watcherConversationId, relayId, relayName = '', remote, digestMinutes = null, quietHours = null, exclude = null } = {}) {
1329
1677
  if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now.');
1330
1678
  requireCross();
1331
1679
  const watcher = toText(watcherConversationId);
@@ -1335,27 +1683,31 @@ export function createRelayEventService({
1335
1683
  if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
1336
1684
  checkWatcherCapacity(watcher);
1337
1685
  const all = remote.scope === 'all';
1686
+ const options = prepareOptions({ all, digestMinutes, quietHours, exclude });
1338
1687
  const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
1339
1688
  .map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
1340
1689
  .filter((session) => session.id);
1341
1690
  const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type));
1342
1691
  const id = `sub_${randomBytes(5).toString('hex')}`;
1343
1692
  const at = isoNow();
1344
- remoteSql.insertMirror.run(
1345
- id,
1346
- watcher,
1347
- relay,
1348
- all ? 'all' : 'sessions',
1349
- all ? null : JSON.stringify(sessions.map((session) => session.id)),
1350
- JSON.stringify(types.length ? types : [...RELAY_EVENT_DEFAULT_TYPES]),
1351
- remote.once === true ? 1 : 0,
1352
- toText(remote.match) ? String(remote.match) : null,
1353
- Number(remote.stalledMinutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
1354
- Number(remote.heartbeatMinutes) > 0 ? Number(remote.heartbeatMinutes) : null,
1355
- at,
1356
- remoteId,
1357
- JSON.stringify({ relayName: toText(relayName) || null, sessions }),
1358
- );
1693
+ db.transaction(() => {
1694
+ remoteSql.insertMirror.run(
1695
+ id,
1696
+ watcher,
1697
+ relay,
1698
+ all ? 'all' : 'sessions',
1699
+ all ? null : JSON.stringify(sessions.map((session) => session.id)),
1700
+ JSON.stringify(types.length ? types : [...RELAY_EVENT_DEFAULT_TYPES]),
1701
+ remote.once === true ? 1 : 0,
1702
+ toText(remote.match) ? String(remote.match) : null,
1703
+ Number(remote.stalledMinutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
1704
+ Number(remote.heartbeatMinutes) > 0 ? Number(remote.heartbeatMinutes) : null,
1705
+ at,
1706
+ remoteId,
1707
+ JSON.stringify({ relayName: toText(relayName) || null, sessions }),
1708
+ );
1709
+ storeOptions(id, options);
1710
+ })();
1359
1711
  noteRemoteLinkOk(relay);
1360
1712
  log(`conv=${shortId(watcher)} subscribed ${id} on a paired relay (${remoteId} there)`);
1361
1713
  broadcast();
@@ -1417,6 +1769,11 @@ export function createRelayEventService({
1417
1769
  prompt: 'text',
1418
1770
  questionId: 'text',
1419
1771
  approvalCard: 'bool',
1772
+ // question_answered
1773
+ answer: 'text',
1774
+ choice: 'text',
1775
+ dismissed: 'bool',
1776
+ via: 'text',
1420
1777
  description: 'text',
1421
1778
  summary: 'text',
1422
1779
  taskId: 'text',
@@ -1482,7 +1839,9 @@ export function createRelayEventService({
1482
1839
  * session changes there end or update the mirrors, confirmed ends clear
1483
1840
  * their sync mark, and the cursor moves, all in one transaction. With
1484
1841
  * `replay` (the first fetch after a gap) events are marked replayed and
1485
- * urgent and nothing is flushed yet: noteRemoteLinkRestored does that.
1842
+ * nothing is flushed yet: noteRemoteLinkRestored does that. They keep to
1843
+ * the subscriptions' options like any event (questions and errors go at
1844
+ * once).
1486
1845
  * Returns per watcher `{ kept, urgent, dropped, ended }`.
1487
1846
  */
1488
1847
  function applyRemotePull(relayId, { relayName = '', response = {}, replay = false } = {}) {
@@ -1503,16 +1862,23 @@ export function createRelayEventService({
1503
1862
  const payload = item?.payload && typeof item.payload === 'object' ? item.payload : null;
1504
1863
  if (!payload || !isRelayEventType(payload.type)) continue;
1505
1864
  const named = (Array.isArray(payload.subscriptionIds) ? payload.subscriptionIds : []).map(toText);
1865
+ const payloadSessionId = payload.session && typeof payload.session === 'object' ? remoteText(payload.session.id, 200) : '';
1506
1866
  const groups = new Map();
1507
1867
  for (const remoteId of named) {
1508
1868
  const row = byRemote.get(remoteId);
1509
1869
  if (!row || row.ended_at) continue;
1870
+ // Left out by this relay's exclude, should that relay not know it.
1871
+ if (excludes(row, payloadSessionId)) continue;
1510
1872
  if (!groups.has(row.watcher_conversation_id)) groups.set(row.watcher_conversation_id, []);
1511
1873
  groups.get(row.watcher_conversation_id).push(row);
1512
1874
  }
1513
1875
  for (const [watcher, mine] of groups) {
1876
+ // No heartbeat inside the quiet window (as for this relay's sessions).
1877
+ if (toText(payload.type) === RELAY_EVENT_HEARTBEAT_TYPE && mine.every((row) => inQuietHours(row))) continue;
1514
1878
  const endingRemote = new Set((Array.isArray(payload.ends) ? payload.ends : []).map(toText));
1515
1879
  const ends = mine.filter((row) => endingRemote.has(row.remote_subscription_id)).map((row) => row.id);
1880
+ // The watcher's own reply it received: dropped, unless it ends a one-shot subscription.
1881
+ if (!ends.length && ownReplyReceived(watcher, { type: toText(payload.type), detail: { messageId: cleanRemoteDetail(payload.detail).messageId } })) continue;
1516
1882
  // The stored event is built from the allow-listed fields of the
1517
1883
  // payload, never from the payload itself.
1518
1884
  const detail = cleanRemoteDetail(payload.detail);
@@ -1532,7 +1898,7 @@ export function createRelayEventService({
1532
1898
  ...(ends.length ? { ends } : {}),
1533
1899
  ...(replay ? { replayed: true } : {}),
1534
1900
  };
1535
- const urgent = replay || item.urgent === true || isUrgentRelayEventType(event.type);
1901
+ const urgent = item.urgent === true || isUrgentRelayEventType(event.type);
1536
1902
  sql.insertEvent.run(mine[0].id, watcher, relay, toText(event.session?.id), event.type, urgent ? 1 : 0, JSON.stringify(event), at);
1537
1903
  if (event.type !== RELAY_EVENT_HEARTBEAT_TYPE) for (const row of mine) sql.markFired.run(at, row.id);
1538
1904
  for (const id of ends) {
@@ -1791,6 +2157,85 @@ export function createRelayEventService({
1791
2157
  return { recorded, skipped };
1792
2158
  }
1793
2159
 
2160
+ // ─── The watcher's own prompts ─────────────────────────────────────────────
2161
+
2162
+ /**
2163
+ * The watcher sent a prompt with send or create_session (`messageId` is
2164
+ * the prompt's, on this relay or a paired one). Returns whether it was noted.
2165
+ */
2166
+ function noteOwnPrompt({ watcherConversationId, relayId = RELAY_EVENT_SELF_RELAY_ID, sessionId, messageId } = {}) {
2167
+ const watcher = toText(watcherConversationId);
2168
+ const id = toText(messageId);
2169
+ if (!ownSql || !watcher || !id) return false;
2170
+ try {
2171
+ ownSql.insert.run(id, watcher, toText(relayId) || RELAY_EVENT_SELF_RELAY_ID, toText(sessionId), isoNow());
2172
+ return true;
2173
+ } catch (error) {
2174
+ warn(`own prompt not noted conv=${shortId(watcher)}: ${toText(error?.message || error).slice(0, 200)}`);
2175
+ return false;
2176
+ }
2177
+ }
2178
+
2179
+ /**
2180
+ * The watcher starts waiting (up to `waitMs`) for the reply to a prompt
2181
+ * it noted: until endOwnWait, that turn's turn_end is held. Returns the
2182
+ * token for endOwnWait, or null for a prompt that is not its own.
2183
+ */
2184
+ function beginOwnWait({ watcherConversationId, messageId, waitMs = 0 } = {}) {
2185
+ const watcher = toText(watcherConversationId);
2186
+ const id = toText(messageId);
2187
+ if (!ownSql || !watcher || !id) return null;
2188
+ try {
2189
+ const own = ownSql.get.get(id);
2190
+ if (!own || own.watcher_conversation_id !== watcher) return null;
2191
+ } catch {
2192
+ return null;
2193
+ }
2194
+ const key = ownWaitKey(watcher, id);
2195
+ const wait = ownWaits.get(key) || { count: 0, until: 0 };
2196
+ wait.count += 1;
2197
+ wait.until = Math.max(wait.until, now() + Math.max(0, Number(waitMs) || 0) + OWN_WAIT_GRACE_MS);
2198
+ ownWaits.set(key, wait);
2199
+ return { watcher, messageId: id };
2200
+ }
2201
+
2202
+ /**
2203
+ * The wait ended. `received`: it returned the finished reply (status done
2204
+ * with the reply), so the turn's turn_end is dropped (unless it ends a
2205
+ * one-shot subscription, which the watcher must hear of) and one that
2206
+ * comes later is not recorded. Otherwise the held turn_end goes as usual.
2207
+ */
2208
+ function endOwnWait(token, { received = false } = {}) {
2209
+ const watcher = toText(token?.watcher);
2210
+ const id = toText(token?.messageId);
2211
+ if (!watcher || !id) return 0;
2212
+ const key = ownWaitKey(watcher, id);
2213
+ const wait = ownWaits.get(key);
2214
+ if (wait) {
2215
+ wait.count -= 1;
2216
+ if (wait.count <= 0) ownWaits.delete(key);
2217
+ }
2218
+ let dropped = 0;
2219
+ if (received && ownSql) {
2220
+ try {
2221
+ db.transaction(() => {
2222
+ ownSql.markReceived.run(isoNow(), id, watcher);
2223
+ for (const row of sql.listUndelivered.all(watcher)) {
2224
+ if (toText(row.type) !== 'turn_end' || Number(row.urgent) === 1) continue;
2225
+ const payload = parseJson(row.payload_json, null);
2226
+ if (toText(payload?.detail?.messageId) !== id || (Array.isArray(payload?.ends) && payload.ends.length)) continue;
2227
+ dropped += sql.deleteEvent.run(row.id).changes;
2228
+ }
2229
+ })();
2230
+ } catch (error) {
2231
+ warn(`own reply not marked conv=${shortId(watcher)}: ${toText(error?.message || error).slice(0, 200)}`);
2232
+ }
2233
+ if (dropped) log(`conv=${shortId(watcher)} received the reply it waited for: ${dropped} turn_end not delivered`);
2234
+ }
2235
+ scheduleFlush(watcher);
2236
+ return dropped;
2237
+ }
2238
+
1794
2239
  // ─── Hooks ─────────────────────────────────────────────────────────────────
1795
2240
 
1796
2241
  function isRelayEventTurn(queueMessageId) {
@@ -1863,9 +2308,67 @@ export function createRelayEventService({
1863
2308
  options,
1864
2309
  questionId: toText(question.id),
1865
2310
  ...(toText(context?.source) === 'remote_relay' ? { approvalCard: true } : {}),
2311
+ // A question that does not block (remote_relay ask).
2312
+ ...(toText(question.kind) === 'ask' ? { kind: 'ask' } : {}),
1866
2313
  });
1867
2314
  }
1868
2315
 
2316
+ /**
2317
+ * The owner answered or dismissed a question that does not block
2318
+ * (services/relay-ask-service.mjs). Each conversation in `notify` (the
2319
+ * session the ask is in and the conversation whose agent asked, both of
2320
+ * this relay) is told at once, whether it subscribed or not; then the
2321
+ * subscriptions on that session that take question_asked, as for any
2322
+ * event (paired relays' through their fetch), each watcher once. For an
2323
+ * ask kept on a paired relay (this relay's agent asked there) `relay` and
2324
+ * `sessionTitle` describe it, and only `notify` is told. Returns how many
2325
+ * conversations or watchers got it.
2326
+ */
2327
+ function noteQuestionAnswered({ sessionId, sessionTitle = null, relay = null, notify = [], detail = {} } = {}) {
2328
+ if (!available) return 0;
2329
+ const id = toText(sessionId);
2330
+ const questionId = toText(detail.questionId);
2331
+ if (!id || !questionId) return 0;
2332
+ const clean = {
2333
+ questionId,
2334
+ prompt: String(detail.prompt ?? '').slice(0, 2000),
2335
+ ...(detail.dismissed === true
2336
+ ? { dismissed: true }
2337
+ : { answer: String(detail.answer ?? '').slice(0, 2000), choice: toText(detail.choice).slice(0, 200) }),
2338
+ kind: 'ask',
2339
+ };
2340
+ // Where the answer came from: a paired relay's Inbox, else this relay
2341
+ // (or, for an ask kept on a paired relay, what that relay said).
2342
+ const remoteId = toText(relay?.id);
2343
+ const remote = !!remoteId && remoteId !== RELAY_EVENT_SELF_RELAY_ID;
2344
+ const via = toText(detail.via).slice(0, 60) || (remote ? toText(relay?.name) : toText(relayName())) || '';
2345
+ if (via) clean.via = via;
2346
+ const event = remote
2347
+ ? {
2348
+ v: RELAY_EVENT_VERSION,
2349
+ relay: { id: remoteId, name: toText(relay.name) || remoteId },
2350
+ session: { id, title: toText(sessionTitle) || null },
2351
+ type: RELAY_EVENT_QUESTION_ANSWERED_TYPE,
2352
+ at: isoNow(),
2353
+ detail: clean,
2354
+ }
2355
+ : buildEvent(RELAY_EVENT_QUESTION_ANSWERED_TYPE, id, clean);
2356
+ const told = new Set();
2357
+ for (const watcher of new Set((Array.isArray(notify) ? notify : []).map(toText).filter(Boolean))) {
2358
+ if (isServedKey(watcher) || !isLive(conversation(watcher))) continue;
2359
+ sql.insertEvent.run(`${ASK_DELIVERY_PREFIX}${questionId}`, watcher, event.relay.id, id, event.type, 1, JSON.stringify({ ...event, subscriptionIds: [] }), event.at);
2360
+ scheduleFlush(watcher, { urgent: true });
2361
+ told.add(watcher);
2362
+ }
2363
+ let recorded = 0;
2364
+ if (!remote) {
2365
+ const rows = localWatchRows().filter((row) => !told.has(row.watcher_conversation_id) && wants(row, event) && subscriptionCovers(row, id));
2366
+ if (rows.length) recorded = enqueueForRows(rows, event, id);
2367
+ }
2368
+ log(`question ${shortId(questionId)} of ${shortId(id)} ${clean.dismissed ? 'dismissed' : 'answered'}: told ${told.size} asking, ${recorded} watching`);
2369
+ return told.size + recorded;
2370
+ }
2371
+
1869
2372
  /** The tracker's edge: a background task started or ended. */
1870
2373
  function noteTaskEdge({ type, conversationId, task } = {}) {
1871
2374
  if (!available || !task) return;
@@ -1922,6 +2425,7 @@ export function createRelayEventService({
1922
2425
  try { for (const r of sql.listRunningRunConversations.all()) ids.add(toText(r.conversation_id)); } catch {}
1923
2426
  for (const id of tracker?.knownConversationIds?.() || []) ids.add(toText(id));
1924
2427
  ids.delete(row.watcher_conversation_id);
2428
+ for (const id of optionsOf(row).exclude) ids.delete(id);
1925
2429
  return [...ids].filter((id) => id && isLive(conversation(id)));
1926
2430
  }
1927
2431
 
@@ -1962,6 +2466,9 @@ export function createRelayEventService({
1962
2466
  const busy = busyEntries(row);
1963
2467
  noteHeartbeatBusy(row, busy.length > 0);
1964
2468
  if (!busy.length) continue;
2469
+ // None inside the quiet window: held, it would only say late that the
2470
+ // watching worked. The first after the window comes at once.
2471
+ if (inQuietHours(row, at)) continue;
1965
2472
  const since = Math.max(busySinceBySubscription.get(row.id) || at, Date.parse(String(row.last_heartbeat_at || '')) || 0);
1966
2473
  if (at - since < minutes * 60_000) continue;
1967
2474
  sql.markHeartbeat.run(new Date(at).toISOString(), row.id);
@@ -2059,6 +2566,10 @@ export function createRelayEventService({
2059
2566
  const at = now();
2060
2567
  const events = sql.deleteDeliveredBefore.run(new Date(at - RELAY_EVENT_DELIVERED_KEEP_MS).toISOString()).changes;
2061
2568
  const subscriptions = deleteEndedSubscriptionsBefore.run(new Date(at - RELAY_EVENT_ENDED_SUBSCRIPTION_KEEP_MS).toISOString()).changes;
2569
+ ownSql?.pruneBefore.run(new Date(at - RELAY_EVENT_OWN_PROMPT_KEEP_MS).toISOString());
2570
+ for (const [key, wait] of ownWaits) {
2571
+ if (wait.until <= at) ownWaits.delete(key);
2572
+ }
2062
2573
  if (events || subscriptions) {
2063
2574
  log(`pruned ${events} delivered event${events === 1 ? '' : 's'} and ${subscriptions} ended subscription${subscriptions === 1 ? '' : 's'}`);
2064
2575
  }
@@ -2131,9 +2642,14 @@ export function createRelayEventService({
2131
2642
  listActive,
2132
2643
  record,
2133
2644
  flush,
2645
+ noteOwnPrompt,
2646
+ beginOwnWait,
2647
+ endOwnWait,
2648
+ areOptionsAvailable: () => optionsAvailable,
2134
2649
  noteTurnEnd,
2135
2650
  noteError,
2136
2651
  noteQuestion,
2652
+ noteQuestionAnswered,
2137
2653
  noteTaskEdge,
2138
2654
  noteConversationGone,
2139
2655
  noteRestartRequested,