@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -0,0 +1,1952 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Relay event subscriptions: a session (the watcher) asks to be told what
5
+ * happens in other sessions of this relay, and the relay tells it by writing
6
+ * a short event message into the watcher's conversation.
7
+ *
8
+ * Delivery is an ordinary queued message of the relay's (`messages.kind =
9
+ * 'relay-event'`, a pending queue row), so the worker decides what it
10
+ * becomes: a new turn when the watcher is idle, a steer into the running turn
11
+ * where the provider steers (Claude, Copilot SDK), the next turn where it
12
+ * cannot (Cursor, Grok, Claude Cloud, the Copilot extension). Events that
13
+ * arrive while the watcher's event row is still pending are written into
14
+ * that row, so a busy watcher gets one follow-up, never one per batch.
15
+ *
16
+ * Cost: events within 30 s are one wake-up; past 20 wake-ups an hour (the
17
+ * event messages of the last hour that carried more than a heartbeat) the
18
+ * rest comes as a digest every ten minutes. question_asked and error go out
19
+ * at once and count; a heartbeat is coalesced like any event, never held
20
+ * for the digest and never counted.
21
+ *
22
+ * A subscription with `heartbeat_minutes` gets a heartbeat event at that
23
+ * interval while its watched set has a turn or task running, none while it
24
+ * is idle: the watcher's sign that its watching still works.
25
+ *
26
+ * `transferSubscriptions` moves every subscription of one watcher, with its
27
+ * undelivered events, to another: a watcher is replaced without a moment
28
+ * where events go nowhere.
29
+ *
30
+ * Subscriptions and undelivered events live in the database
31
+ * (migrations/0011-relay-event-subscriptions.mjs); timers are rebuilt at
32
+ * boot. A subscription ends on unsubscribe, when its watcher is archived or
33
+ * deleted, when its last listed session is, or after firing (`once`).
34
+ *
35
+ * Across paired relays (migrations/0012-cross-relay-event-subscriptions.mjs)
36
+ * this service plays both sides:
37
+ *
38
+ * - Watched ("served"): a paired relay's agent subscribes here through
39
+ * subscribeServed. The row is an ordinary subscription whose watcher is
40
+ * the key `remote:<that relay's id>`, which names no conversation: its
41
+ * events are recorded like any, never written into a conversation, and
42
+ * wait until that relay fetches them (pullServed), at most
43
+ * remoteKeepEvents per subscription and remoteKeepDays old.
44
+ * - Watching ("mirror"): a row with `relay_id` = the paired relay's registry
45
+ * id and `remote_subscription_id` = its id there. Mirrors record nothing
46
+ * and are skipped by the sweeps; applyRemotePull writes what the link
47
+ * service fetched into the watcher's undelivered events, from where it is
48
+ * delivered like the events of this relay's sessions.
49
+ */
50
+
51
+ import { randomBytes, randomUUID } from 'node:crypto';
52
+
53
+ import {
54
+ RELAY_EVENT_DEFAULT_TYPES,
55
+ RELAY_EVENT_HEARTBEAT_TYPE,
56
+ RELAY_EVENT_LIMITS,
57
+ RELAY_EVENT_LINK_LOST_TYPE,
58
+ RELAY_EVENT_LINK_RESTORED_TYPE,
59
+ RELAY_EVENT_MESSAGE_KIND,
60
+ RELAY_EVENT_TYPES,
61
+ RELAY_EVENT_VERSION,
62
+ buildRelayEventMessageText,
63
+ compileRelayEventMatch,
64
+ isRelayEventType,
65
+ isUrgentRelayEventType,
66
+ relayEventMatchText,
67
+ } from '../../shared/relay-events.mjs';
68
+
69
+ export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
70
+ export const RELAY_EVENT_SELF_RELAY_ID = 'self';
71
+ // The watcher key of a paired relay's subscriptions here: never a
72
+ // conversation id, so nothing a paired relay asks for is ever written into
73
+ // one of this relay's conversations.
74
+ export const RELAY_EVENT_SERVED_WATCHER_PREFIX = 'remote:';
75
+
76
+ const HOUR_MS = 60 * 60_000;
77
+ const DAY_MS = 24 * HOUR_MS;
78
+ const SWEEP_INTERVAL_MS = 30_000;
79
+ // Delivered events are read for wake counting (the last hour) and to rebuild
80
+ // a still-pending event row's text; past this they are of no use and go.
81
+ export const RELAY_EVENT_DELIVERED_KEEP_MS = 2 * HOUR_MS;
82
+ // An ended subscription is listed for an hour and, for a paired relay, its
83
+ // end is sent for remoteKeepDays; past that it is history nobody reads.
84
+ export const RELAY_EVENT_ENDED_SUBSCRIPTION_KEEP_MS = RELAY_EVENT_LIMITS.remoteKeepDays * DAY_MS;
85
+ const PRUNE_INTERVAL_MS = 10 * 60_000;
86
+
87
+ // Retention of what paired relays have not fetched. A served event is never
88
+ // marked delivered (its fetch deletes it), and `delivered_at IS NULL` is the
89
+ // partial index's condition: without it SQLite scanned the whole table on
90
+ // every sweep. The watcher range is the served keys ('remote:' up to the
91
+ // next character); LIKE could not use the index.
92
+ const SERVED_RANGE = `watcher_conversation_id >= 'remote:' AND watcher_conversation_id < 'remote;' AND delivered_at IS NULL`;
93
+ export const RELAY_EVENT_SERVED_RETENTION_SQL = Object.freeze({
94
+ listServedOverfull: `
95
+ SELECT subscription_id, COUNT(*) AS n FROM relay_events
96
+ WHERE ${SERVED_RANGE} GROUP BY subscription_id HAVING n > ?
97
+ `,
98
+ // Oldest first, questions and errors last.
99
+ deleteServedOldest: `
100
+ DELETE FROM relay_events WHERE id IN (
101
+ SELECT id FROM relay_events WHERE ${SERVED_RANGE} AND subscription_id = ?
102
+ ORDER BY urgent ASC, id ASC LIMIT ?
103
+ )
104
+ `,
105
+ countServedExpired: `
106
+ SELECT subscription_id, COUNT(*) AS n FROM relay_events
107
+ WHERE ${SERVED_RANGE} AND created_at < ? GROUP BY subscription_id
108
+ `,
109
+ deleteServedExpired: `DELETE FROM relay_events WHERE ${SERVED_RANGE} AND created_at < ?`,
110
+ });
111
+ // Delivered events past their keep, unless their event row is still pending
112
+ // (its text is rebuilt from them when more events are appended).
113
+ export const RELAY_EVENT_PRUNE_SQL = Object.freeze({
114
+ deleteDeliveredBefore: `
115
+ DELETE FROM relay_events
116
+ WHERE delivered_at IS NOT NULL AND delivered_at < ?
117
+ AND NOT EXISTS (SELECT 1 FROM queue WHERE queue.id = relay_events.message_id AND queue.status IN ('pending', 'parked'))
118
+ `,
119
+ // An ended mirror whose end the paired relay has not confirmed waits.
120
+ deleteEndedSubscriptionsBefore: `
121
+ DELETE FROM relay_event_subscriptions WHERE ended_at IS NOT NULL AND ended_at < ? AND remote_sync IS NULL
122
+ `,
123
+ deleteEndedSubscriptionsBeforeNoSync: `
124
+ DELETE FROM relay_event_subscriptions WHERE ended_at IS NOT NULL AND ended_at < ?
125
+ `,
126
+ });
127
+ // A watched set must look idle twice, this far apart, before all_idle fires:
128
+ // a task's end and the continuation turn it opens are seconds apart.
129
+ const ALL_IDLE_SETTLE_MS = 10_000;
130
+ const LOG_PREFIX = '[relay-events]';
131
+
132
+ function toText(value) {
133
+ return String(value ?? '').trim();
134
+ }
135
+
136
+ function shortId(value) {
137
+ return toText(value).slice(0, 8);
138
+ }
139
+
140
+ /** A paired relay's own id as it states it: kept to a safe, short shape. */
141
+ export function cleanSubscriberRelayId(value) {
142
+ const text = toText(value);
143
+ return /^[A-Za-z0-9._:-]{1,100}$/.test(text) ? text : '';
144
+ }
145
+
146
+ export function servedWatcherKey(subscriberRelayId) {
147
+ return `${RELAY_EVENT_SERVED_WATCHER_PREFIX}${subscriberRelayId}`;
148
+ }
149
+
150
+ function isServedKey(watcher) {
151
+ return toText(watcher).startsWith(RELAY_EVENT_SERVED_WATCHER_PREFIX);
152
+ }
153
+
154
+ /** A subscription of this relay's watcher on a paired relay's sessions. */
155
+ function isMirror(row) {
156
+ return !!row && !!toText(row.remote_subscription_id) && toText(row.relay_id) !== RELAY_EVENT_SELF_RELAY_ID;
157
+ }
158
+
159
+ function parseJson(value, fallback) {
160
+ try {
161
+ const parsed = JSON.parse(String(value ?? ''));
162
+ return parsed ?? fallback;
163
+ } catch {
164
+ return fallback;
165
+ }
166
+ }
167
+
168
+ export class RelayEventError extends Error {
169
+ constructor(code, message, extra = {}) {
170
+ super(message);
171
+ this.code = code;
172
+ this.extra = extra;
173
+ }
174
+ }
175
+
176
+ export function createRelayEventService({
177
+ db,
178
+ stmts,
179
+ tracker = null,
180
+ emit = () => {},
181
+ wakeWatcher = () => {},
182
+ relayName = () => '',
183
+ now = () => Date.now(),
184
+ setTimer = (fn, ms) => {
185
+ const timer = setTimeout(fn, ms);
186
+ timer.unref?.();
187
+ return timer;
188
+ },
189
+ clearTimer = (timer) => clearTimeout(timer),
190
+ logger = console,
191
+ sweepIntervalMs = SWEEP_INTERVAL_MS,
192
+ // A mirror changed in a way the paired relay must learn (an end, a new
193
+ // watcher): the link service fetches from that relay now.
194
+ onRemoteChange = () => {},
195
+ } = {}) {
196
+ if (!db) throw new Error('db is required');
197
+ if (!stmts) throw new Error('stmts is required');
198
+
199
+ const tableExists = (name) => !!db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(name);
200
+ const available = tableExists('relay_event_subscriptions') && tableExists('relay_events');
201
+ // Migration 0012: without it subscriptions keep to this relay's sessions.
202
+ const crossAvailable = available && tableExists('relay_event_links') && db
203
+ .prepare(`PRAGMA table_info(relay_event_subscriptions)`).all().some((column) => column.name === 'subscriber_relay_id');
204
+
205
+ const sql = available ? {
206
+ insertSubscription: db.prepare(`
207
+ INSERT INTO relay_event_subscriptions
208
+ (id, watcher_conversation_id, relay_id, scope, session_ids_json, events_json, once, match, stalled_minutes, heartbeat_minutes, created_at)
209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
210
+ `),
211
+ getSubscription: db.prepare(`SELECT * FROM relay_event_subscriptions WHERE id = ?`),
212
+ listActive: db.prepare(`SELECT * FROM relay_event_subscriptions WHERE ended_at IS NULL ORDER BY created_at ASC`),
213
+ listForWatcher: db.prepare(`
214
+ SELECT * FROM relay_event_subscriptions
215
+ WHERE watcher_conversation_id = ? AND (ended_at IS NULL OR ended_at >= ?)
216
+ ORDER BY created_at ASC
217
+ `),
218
+ endSubscription: db.prepare(`UPDATE relay_event_subscriptions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL`),
219
+ setSessions: db.prepare(`UPDATE relay_event_subscriptions SET session_ids_json = ? WHERE id = ? AND ended_at IS NULL`),
220
+ markFired: db.prepare(`UPDATE relay_event_subscriptions SET fired_count = fired_count + 1, last_event_at = ? WHERE id = ?`),
221
+ markHeartbeat: db.prepare(`UPDATE relay_event_subscriptions SET last_heartbeat_at = ? WHERE id = ?`),
222
+ moveSubscriptions: db.prepare(`
223
+ UPDATE relay_event_subscriptions SET watcher_conversation_id = ? WHERE watcher_conversation_id = ? AND ended_at IS NULL
224
+ `),
225
+ moveUndelivered: db.prepare(`
226
+ UPDATE relay_events SET watcher_conversation_id = ? WHERE watcher_conversation_id = ? AND delivered_at IS NULL
227
+ `),
228
+ dropUndeliveredAbout: db.prepare(`
229
+ DELETE FROM relay_events WHERE watcher_conversation_id = ? AND session_id = ? AND delivered_at IS NULL
230
+ `),
231
+ insertEvent: db.prepare(`
232
+ INSERT INTO relay_events (subscription_id, watcher_conversation_id, relay_id, session_id, type, urgent, payload_json, created_at)
233
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
234
+ `),
235
+ listUndelivered: db.prepare(`
236
+ SELECT * FROM relay_events WHERE watcher_conversation_id = ? AND delivered_at IS NULL ORDER BY id ASC
237
+ `),
238
+ listWatchersWithUndelivered: db.prepare(`
239
+ SELECT DISTINCT watcher_conversation_id FROM relay_events WHERE delivered_at IS NULL
240
+ `),
241
+ listEventsOfMessage: db.prepare(`SELECT * FROM relay_events WHERE message_id = ? ORDER BY id ASC`),
242
+ markDelivered: db.prepare(`UPDATE relay_events SET delivered_at = ?, message_id = ? WHERE id = ?`),
243
+ dropUndelivered: db.prepare(`DELETE FROM relay_events WHERE watcher_conversation_id = ? AND delivered_at IS NULL`),
244
+ // Wake-ups: the event messages that carried more than a heartbeat.
245
+ countWakes: db.prepare(`
246
+ SELECT COUNT(DISTINCT message_id) AS n FROM relay_events
247
+ WHERE watcher_conversation_id = ? AND delivered_at > ? AND type <> ? AND message_id IS NOT NULL
248
+ `),
249
+ lastWake: db.prepare(`
250
+ SELECT MAX(delivered_at) AS at FROM relay_events
251
+ WHERE watcher_conversation_id = ? AND type <> ? AND delivered_at IS NOT NULL
252
+ `),
253
+ findPendingEventRow: db.prepare(`
254
+ SELECT q.id, q.text FROM queue q JOIN messages m ON m.id = q.id
255
+ WHERE q.conversation_id = ? AND q.status = 'pending' AND m.kind = ?
256
+ ORDER BY q.timestamp DESC LIMIT 1
257
+ `),
258
+ updateQueueText: db.prepare(`UPDATE queue SET text = ? WHERE id = ? AND status = 'pending'`),
259
+ updateMessageText: db.prepare(`UPDATE messages SET text = ? WHERE id = ?`),
260
+ getMessageTimestamp: db.prepare(`SELECT timestamp FROM messages WHERE id = ?`),
261
+ getConversation: db.prepare(`
262
+ SELECT id, title, archived, status, preferred_relay_mode, preferred_reasoning_effort
263
+ FROM conversations WHERE id = ?
264
+ `),
265
+ getQueueMessageKind: db.prepare(`SELECT kind FROM messages WHERE id = ?`),
266
+ // A session waiting on a question is not stalled: question_asked said it.
267
+ hasPendingQuestion: (() => {
268
+ try {
269
+ return db.prepare(`SELECT 1 AS found FROM relay_questions WHERE conversation_id = ? AND status = 'pending' LIMIT 1`);
270
+ } catch {
271
+ return null;
272
+ }
273
+ })(),
274
+ // Sessions with a turn queued or running, for `all` subscriptions.
275
+ listBusyConversations: db.prepare(`
276
+ SELECT DISTINCT conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')
277
+ `),
278
+ listRunningRunConversations: db.prepare(`
279
+ SELECT DISTINCT conversation_id FROM subagent_runs WHERE status = 'running'
280
+ `),
281
+ // Turns other than ones answering relay events: a watcher woken by an
282
+ // event is not "work" for another watcher's all_idle.
283
+ countNonEventTurns: db.prepare(`
284
+ SELECT COUNT(*) AS n FROM queue q LEFT JOIN messages m ON m.id = q.id
285
+ WHERE q.conversation_id = ? AND q.status IN ('pending', 'processing', 'parked')
286
+ AND COALESCE(m.kind, '') <> ?
287
+ `),
288
+ deleteDeliveredBefore: db.prepare(RELAY_EVENT_PRUNE_SQL.deleteDeliveredBefore),
289
+ } : null;
290
+ // Without migration 0012 the table has no remote_sync column.
291
+ const deleteEndedSubscriptionsBefore = available
292
+ ? db.prepare(crossAvailable ? RELAY_EVENT_PRUNE_SQL.deleteEndedSubscriptionsBefore : RELAY_EVENT_PRUNE_SQL.deleteEndedSubscriptionsBeforeNoSync)
293
+ : null;
294
+
295
+ const remoteSql = crossAvailable ? {
296
+ insertServed: db.prepare(`
297
+ INSERT INTO relay_event_subscriptions
298
+ (id, watcher_conversation_id, relay_id, scope, session_ids_json, events_json, once, match, stalled_minutes, heartbeat_minutes, created_at,
299
+ subscriber_relay_id, subscriber_relay_name, subscriber_watcher_id, subscriber_watcher_title, last_pull_at)
300
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
301
+ `),
302
+ insertMirror: db.prepare(`
303
+ INSERT INTO relay_event_subscriptions
304
+ (id, watcher_conversation_id, relay_id, scope, session_ids_json, events_json, once, match, stalled_minutes, heartbeat_minutes, created_at,
305
+ remote_subscription_id, remote_meta_json)
306
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
307
+ `),
308
+ listServed: db.prepare(`SELECT * FROM relay_event_subscriptions WHERE subscriber_relay_id = ?`),
309
+ countServedActive: db.prepare(`SELECT COUNT(*) AS n FROM relay_event_subscriptions WHERE subscriber_relay_id = ? AND ended_at IS NULL`),
310
+ countServedOfWatcher: db.prepare(`
311
+ SELECT COUNT(*) AS n FROM relay_event_subscriptions WHERE subscriber_relay_id = ? AND subscriber_watcher_id = ? AND ended_at IS NULL
312
+ `),
313
+ touchServed: db.prepare(`UPDATE relay_event_subscriptions SET last_pull_at = ? WHERE subscriber_relay_id = ? AND ended_at IS NULL`),
314
+ setServedWatcher: db.prepare(`UPDATE relay_event_subscriptions SET subscriber_watcher_id = ?, subscriber_watcher_title = ? WHERE id = ?`),
315
+ takeDropped: db.prepare(`UPDATE relay_event_subscriptions SET dropped_count = 0 WHERE id = ?`),
316
+ addDropped: db.prepare(`UPDATE relay_event_subscriptions SET dropped_count = dropped_count + ? WHERE id = ?`),
317
+ deleteServedThrough: db.prepare(`DELETE FROM relay_events WHERE watcher_conversation_id = ? AND id <= ?`),
318
+ listServedEvents: db.prepare(`SELECT * FROM relay_events WHERE watcher_conversation_id = ? AND id > ? ORDER BY id ASC LIMIT ?`),
319
+ // Retention: what is past the bound, per subscription (the row's first).
320
+ listServedOverfull: db.prepare(RELAY_EVENT_SERVED_RETENTION_SQL.listServedOverfull),
321
+ deleteServedOldest: db.prepare(RELAY_EVENT_SERVED_RETENTION_SQL.deleteServedOldest),
322
+ countServedExpired: db.prepare(RELAY_EVENT_SERVED_RETENTION_SQL.countServedExpired),
323
+ deleteServedExpired: db.prepare(RELAY_EVENT_SERVED_RETENTION_SQL.deleteServedExpired),
324
+ listServedStale: db.prepare(`
325
+ SELECT id FROM relay_event_subscriptions
326
+ WHERE subscriber_relay_id IS NOT NULL AND ended_at IS NULL AND COALESCE(last_pull_at, created_at) < ?
327
+ `),
328
+ // Mirrors of one paired relay: active ones, and ended ones whose end has
329
+ // not reached it yet.
330
+ listMirrorsOfRelay: db.prepare(`
331
+ SELECT * FROM relay_event_subscriptions
332
+ WHERE relay_id = ? AND remote_subscription_id IS NOT NULL AND (ended_at IS NULL OR remote_sync IS NOT NULL)
333
+ ORDER BY created_at ASC
334
+ `),
335
+ listRelaysToPoll: db.prepare(`
336
+ SELECT DISTINCT relay_id FROM relay_event_subscriptions
337
+ WHERE remote_subscription_id IS NOT NULL AND (ended_at IS NULL OR remote_sync IS NOT NULL)
338
+ `),
339
+ setRemoteSync: db.prepare(`UPDATE relay_event_subscriptions SET remote_sync = ? WHERE id = ?`),
340
+ setMirrorSessions: db.prepare(`UPDATE relay_event_subscriptions SET session_ids_json = ?, remote_meta_json = ? WHERE id = ?`),
341
+ clearRelaySync: db.prepare(`UPDATE relay_event_subscriptions SET remote_sync = NULL WHERE relay_id = ? AND remote_subscription_id IS NOT NULL`),
342
+ // An end the paired relay never confirmed: past remoteKeepDays it ended
343
+ // the subscription itself (nobody fetched).
344
+ clearStaleSync: db.prepare(`UPDATE relay_event_subscriptions SET remote_sync = NULL WHERE remote_sync IS NOT NULL AND ended_at < ?`),
345
+ getLink: db.prepare(`SELECT * FROM relay_event_links WHERE relay_id = ?`),
346
+ linkOk: db.prepare(`
347
+ INSERT INTO relay_event_links (relay_id, cursor, last_ok_at) VALUES (?, ?, ?)
348
+ ON CONFLICT(relay_id) DO UPDATE SET cursor = MAX(cursor, excluded.cursor), last_ok_at = excluded.last_ok_at,
349
+ first_fail_at = NULL, lost_at = NULL, last_error = NULL
350
+ `),
351
+ linkFailed: db.prepare(`
352
+ INSERT INTO relay_event_links (relay_id, first_fail_at, last_error) VALUES (?, ?, ?)
353
+ ON CONFLICT(relay_id) DO UPDATE SET first_fail_at = COALESCE(first_fail_at, excluded.first_fail_at), last_error = excluded.last_error
354
+ `),
355
+ linkLost: db.prepare(`UPDATE relay_event_links SET lost_at = ? WHERE relay_id = ? AND lost_at IS NULL`),
356
+ deleteLink: db.prepare(`DELETE FROM relay_event_links WHERE relay_id = ?`),
357
+ moveSubscriptionsOfRelay: db.prepare(`
358
+ UPDATE relay_event_subscriptions SET watcher_conversation_id = ? WHERE watcher_conversation_id = ? AND ended_at IS NULL AND relay_id = ?
359
+ `),
360
+ moveUndeliveredOfRelay: db.prepare(`
361
+ UPDATE relay_events SET watcher_conversation_id = ? WHERE watcher_conversation_id = ? AND delivered_at IS NULL AND relay_id = ?
362
+ `),
363
+ } : null;
364
+
365
+ // watcher conversation id → { timer, dueAt }
366
+ const flushTimers = new Map();
367
+ // `${subscriptionId}:${sessionId}` → the activity time a stall was reported at
368
+ const stallReported = new Map();
369
+ // all_idle: subscription id → whether its watched set was busy at the last
370
+ // look, and since when it has looked idle after being busy.
371
+ const busyBySubscription = new Map();
372
+ const idleSinceBySubscription = new Map();
373
+ // heartbeat: subscription id → since when its watched set has been busy
374
+ const busySinceBySubscription = new Map();
375
+ let allIdleTimer = null;
376
+ let sweepTimer = null;
377
+ let started = false;
378
+ let lastPruneAt = 0;
379
+
380
+ function isoNow() {
381
+ return new Date(now()).toISOString();
382
+ }
383
+
384
+ function log(line) {
385
+ try { logger?.log?.(`${LOG_PREFIX} ${line}`); } catch {}
386
+ }
387
+
388
+ function warn(line) {
389
+ try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
390
+ }
391
+
392
+ function conversation(id) {
393
+ const key = toText(id);
394
+ if (!key) return null;
395
+ try {
396
+ return sql.getConversation.get(key) || null;
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+
402
+ function isLive(row) {
403
+ return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
404
+ }
405
+
406
+ function shapeSubscription(row) {
407
+ if (!row) return null;
408
+ const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
409
+ const sessionIds = scope === 'all' ? [] : parseJson(row.session_ids_json, []).map(toText).filter(Boolean);
410
+ const mirror = isMirror(row);
411
+ const meta = mirror ? parseJson(row.remote_meta_json, {}) : null;
412
+ const remoteTitles = new Map((Array.isArray(meta?.sessions) ? meta.sessions : []).map((session) => [toText(session?.id), toText(session?.title) || null]));
413
+ return {
414
+ id: row.id,
415
+ watcher: row.watcher_conversation_id,
416
+ relay: toText(row.relay_id) || RELAY_EVENT_SELF_RELAY_ID,
417
+ ...(mirror ? {
418
+ relayName: toText(meta?.relayName) || null,
419
+ remoteId: row.remote_subscription_id,
420
+ ...(toText(row.remote_sync) ? { pendingSync: toText(row.remote_sync) } : {}),
421
+ } : {}),
422
+ ...(toText(row.subscriber_relay_id) ? {
423
+ subscriber: {
424
+ relayId: row.subscriber_relay_id,
425
+ name: toText(row.subscriber_relay_name) || null,
426
+ watcherId: toText(row.subscriber_watcher_id) || null,
427
+ watcherTitle: toText(row.subscriber_watcher_title) || null,
428
+ },
429
+ } : {}),
430
+ scope,
431
+ sessions: scope === 'all'
432
+ ? 'all'
433
+ : sessionIds.map((id) => ({ id, title: mirror ? (remoteTitles.get(id) || null) : (toText(conversation(id)?.title) || null) })),
434
+ events: parseJson(row.events_json, []).filter((type) => RELAY_EVENT_TYPES.includes(type)),
435
+ once: Number(row.once || 0) === 1,
436
+ ...(toText(row.match) ? { match: row.match } : {}),
437
+ stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
438
+ ...(Number(row.heartbeat_minutes) > 0 ? { heartbeatMinutes: Number(row.heartbeat_minutes) } : {}),
439
+ createdAt: row.created_at,
440
+ ...(row.ended_at ? { endedAt: row.ended_at, endReason: row.end_reason || null } : {}),
441
+ firedCount: Number(row.fired_count || 0),
442
+ lastEventAt: row.last_event_at || null,
443
+ };
444
+ }
445
+
446
+ function activeRows() {
447
+ if (!available) return [];
448
+ try {
449
+ return sql.listActive.all();
450
+ } catch {
451
+ return [];
452
+ }
453
+ }
454
+
455
+ function broadcast() {
456
+ try {
457
+ emit(RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT, { subscriptions: listActive() });
458
+ } catch {}
459
+ }
460
+
461
+ function listActive() {
462
+ return activeRows().map(shapeSubscription);
463
+ }
464
+
465
+ /** The subscriptions whose sessions live here: this relay's watchers' and paired relays'. */
466
+ function localWatchRows() {
467
+ return activeRows().filter((row) => !isMirror(row));
468
+ }
469
+
470
+ function checkWatcherCapacity(watcherConversationId, adding = 1) {
471
+ const watcher = toText(watcherConversationId);
472
+ const count = activeRows().filter((row) => row.watcher_conversation_id === watcher).length;
473
+ if (count + adding > RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher) {
474
+ throw new RelayEventError('limit', `This conversation already has ${count} subscriptions; unsubscribe one first.`);
475
+ }
476
+ }
477
+
478
+ /** A watcher's subscriptions: the active ones and those that ended within the hour. */
479
+ function listSubscriptions({ watcherConversationId, includeEndedSinceMs = HOUR_MS } = {}) {
480
+ if (!available) return [];
481
+ const since = new Date(now() - includeEndedSinceMs).toISOString();
482
+ return sql.listForWatcher.all(toText(watcherConversationId), since).map(shapeSubscription);
483
+ }
484
+
485
+ // ─── Subscribe / unsubscribe ───────────────────────────────────────────────
486
+
487
+ /**
488
+ * What a subscribe asks for, checked: `{ all, ids, types, matchText,
489
+ * stalled, heartbeat }`. Throws RelayEventError (`invalid`, `notFound`).
490
+ * `exclude` is the watcher, never watched by itself.
491
+ */
492
+ function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '' }) {
493
+ const all = scope === 'all';
494
+ const ids = all ? [] : [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map(toText).filter(Boolean))];
495
+ if (!all && !ids.length) throw new RelayEventError('invalid', 'subscribe needs sessions (ids from list_sessions) or "all".');
496
+ if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
497
+ throw new RelayEventError('invalid', `subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use "all" for more.`);
498
+ }
499
+ if (exclude && ids.includes(exclude)) {
500
+ 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.`);
501
+ }
502
+ const missing = ids.filter((id) => !isLive(conversation(id)));
503
+ if (missing.length) {
504
+ throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
505
+ }
506
+ const types = Array.isArray(events) && events.length
507
+ ? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_TYPES.includes(type)))]
508
+ : [...RELAY_EVENT_DEFAULT_TYPES];
509
+ if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_TYPES.join(', ')}`);
510
+ const compiled = compileRelayEventMatch(match);
511
+ if (!compiled.ok) throw new RelayEventError('invalid', compiled.error);
512
+ const stalled = Number.isFinite(Number(stalledMinutes)) && stalledMinutes !== null && stalledMinutes !== ''
513
+ ? Math.min(RELAY_EVENT_LIMITS.stalledMinutesMax, Math.max(RELAY_EVENT_LIMITS.stalledMinutesMin, Math.trunc(Number(stalledMinutes))))
514
+ : RELAY_EVENT_LIMITS.stalledMinutesDefault;
515
+ const heartbeat = heartbeatMinutes === null || heartbeatMinutes === undefined || heartbeatMinutes === ''
516
+ ? null
517
+ : Math.min(RELAY_EVENT_LIMITS.heartbeatMinutesMax, Math.max(RELAY_EVENT_LIMITS.heartbeatMinutesMin, Math.trunc(Number(heartbeatMinutes))));
518
+ if (heartbeat !== null && !Number.isFinite(heartbeat)) throw new RelayEventError('invalid', 'heartbeat_minutes must be a number of minutes');
519
+ return { all, ids, types, matchText: toText(match) ? String(match) : null, stalled, heartbeat };
520
+ }
521
+
522
+ /**
523
+ * A new subscription of `watcherConversationId`. `sessionIds` lists the
524
+ * watched sessions, or `scope: 'all'` watches every session of the relay,
525
+ * later ones included. Throws RelayEventError (code `invalid`, `notFound`,
526
+ * `ownSession`, `limit`, `unavailable`).
527
+ */
528
+ function subscribe({
529
+ watcherConversationId,
530
+ scope = 'sessions',
531
+ sessionIds = [],
532
+ events = null,
533
+ once = false,
534
+ match = '',
535
+ stalledMinutes = null,
536
+ heartbeatMinutes = null,
537
+ } = {}) {
538
+ if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
539
+ const watcher = toText(watcherConversationId);
540
+ if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
541
+ const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher });
542
+ const { all, ids, types } = prepared;
543
+ checkWatcherCapacity(watcher);
544
+ const id = `sub_${randomBytes(5).toString('hex')}`;
545
+ const at = isoNow();
546
+ sql.insertSubscription.run(
547
+ id,
548
+ watcher,
549
+ RELAY_EVENT_SELF_RELAY_ID,
550
+ all ? 'all' : 'sessions',
551
+ all ? null : JSON.stringify(ids),
552
+ JSON.stringify(types),
553
+ once ? 1 : 0,
554
+ prepared.matchText,
555
+ prepared.stalled,
556
+ prepared.heartbeat,
557
+ at,
558
+ );
559
+ const subscription = shapeSubscription(sql.getSubscription.get(id));
560
+ // The watched set starts from what it is doing now: all_idle fires when
561
+ // work that is running now (or starts later) has ended.
562
+ noteBusy(sql.getSubscription.get(id));
563
+ log(`conv=${shortId(watcher)} subscribed ${id} to ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${types.join(',')})${once ? ' once' : ''}`);
564
+ broadcast();
565
+ return subscription;
566
+ }
567
+
568
+ function endSubscription(id, reason) {
569
+ const changed = sql.endSubscription.run(isoNow(), reason, id).changes > 0;
570
+ busyBySubscription.delete(id);
571
+ idleSinceBySubscription.delete(id);
572
+ busySinceBySubscription.delete(id);
573
+ for (const key of [...stallReported.keys()]) {
574
+ if (key.startsWith(`${id}:`)) stallReported.delete(key);
575
+ }
576
+ return changed;
577
+ }
578
+
579
+ /**
580
+ * Ends one subscription of the watcher (`subscriptionId`), or all of them
581
+ * (`'all'`). Without a watcher (the browser), any active subscription.
582
+ * Returns the ended subscriptions.
583
+ */
584
+ /**
585
+ * Ends a mirror here and leaves its end for the paired relay to learn on
586
+ * the next fetch (at once when it answers, later when it does not).
587
+ */
588
+ function endMirror(row, reason) {
589
+ if (!endSubscription(row.id, reason)) return false;
590
+ try { remoteSql?.setRemoteSync.run('unsubscribe', row.id); } catch {}
591
+ return true;
592
+ }
593
+
594
+ function notifyRemoteChange(relayIds) {
595
+ for (const relayId of new Set(relayIds)) {
596
+ try { onRemoteChange(relayId); } catch {}
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Ends one subscription of the watcher (`subscriptionId`: its id, or for
602
+ * one on a paired relay also that relay's id for it), or all of them
603
+ * (`'all'`; with `relayId` only those watching that relay). Without a
604
+ * watcher (the browser), any active subscription. Returns the ended ones.
605
+ */
606
+ function unsubscribe({ watcherConversationId = '', subscriptionId, reason = 'unsubscribed', relayId = null } = {}) {
607
+ if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now.');
608
+ const watcher = toText(watcherConversationId);
609
+ const target = toText(subscriptionId);
610
+ if (!target) throw new RelayEventError('invalid', 'unsubscribe needs subscription_id (from subscribe or list_subscriptions), or "all".');
611
+ const relay = relayId === null ? null : toText(relayId);
612
+ const rows = activeRows().filter((row) => (!watcher || row.watcher_conversation_id === watcher)
613
+ && (target === 'all'
614
+ ? !!watcher && (relay === null || toText(row.relay_id) === relay)
615
+ : row.id === target || (isMirror(row) && row.remote_subscription_id === target)));
616
+ if (!rows.length) {
617
+ throw new RelayEventError('notFound', target === 'all'
618
+ ? `This conversation has no active subscriptions${relay && relay !== RELAY_EVENT_SELF_RELAY_ID ? ' on that relay' : ''}.`
619
+ : `No active subscription ${target}${watcher ? ' of this conversation' : ''}.`);
620
+ }
621
+ const ended = [];
622
+ const remoteRelays = [];
623
+ for (const row of rows) {
624
+ const done = isMirror(row) ? endMirror(row, reason) : endSubscription(row.id, reason);
625
+ if (!done) continue;
626
+ if (isMirror(row)) remoteRelays.push(toText(row.relay_id));
627
+ ended.push(shapeSubscription(sql.getSubscription.get(row.id)));
628
+ }
629
+ if (ended.length) {
630
+ log(`${ended.map((sub) => sub.id).join(', ')} ended (${reason})`);
631
+ dropOrphanedEvents(new Set(ended.map((sub) => sub.watcher)));
632
+ broadcast();
633
+ notifyRemoteChange(remoteRelays);
634
+ }
635
+ return ended;
636
+ }
637
+
638
+ /**
639
+ * What a transfer from `fromConversationId` to `toConversationId` would
640
+ * move, for the approval card: `{ from: { id, title }, count, includesAll,
641
+ * subscriptions }`. Throws RelayEventError like transferSubscriptions.
642
+ */
643
+ function previewTransfer({ fromConversationId, toConversationId, relayId = null } = {}) {
644
+ if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now.');
645
+ const from = toText(fromConversationId);
646
+ const to = toText(toConversationId);
647
+ const relay = relayId === null ? null : toText(relayId);
648
+ if (!from) throw new RelayEventError('invalid', 'transfer_subscriptions needs from (the id of the conversation that watches now).');
649
+ if (from === to) throw new RelayEventError('invalid', 'from is this conversation: its subscriptions are already here.');
650
+ if (!isLive(conversation(to))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
651
+ const fromRow = conversation(from);
652
+ const rows = activeRows().filter((row) => row.watcher_conversation_id === from && (relay === null || toText(row.relay_id) === relay));
653
+ if (!rows.length) {
654
+ throw new RelayEventError('notFound', fromRow
655
+ ? `Session ${from} has no active subscriptions${relay !== null ? ' on that relay' : ''} to transfer${isLive(fromRow) ? '' : ' (it is archived, which ended them)'}.`
656
+ : `No session with id ${from} on this relay.`);
657
+ }
658
+ const mine = activeRows().filter((row) => row.watcher_conversation_id === to).length;
659
+ if (mine + rows.length > RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher) {
660
+ throw new RelayEventError('limit', `This conversation has ${mine} subscriptions; with ${rows.length} more it would pass the limit of ${RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher}.`);
661
+ }
662
+ const subscriptions = rows.map(shapeSubscription);
663
+ return {
664
+ from: { id: from, title: toText(fromRow?.title) || null },
665
+ count: rows.length,
666
+ includesAll: rows.some((row) => toText(row.scope) === 'all'),
667
+ // The paired relays among them, by name, for the card.
668
+ relays: [...new Set(subscriptions.filter((sub) => sub.remoteId).map((sub) => sub.relayName || sub.relay))],
669
+ subscriptions,
670
+ };
671
+ }
672
+
673
+ /**
674
+ * Moves every active subscription of `fromConversationId`, its settings
675
+ * kept, and its undelivered events to `toConversationId`. The new watcher
676
+ * is dropped from the moved session lists (it is never watched); a list
677
+ * left empty ends. Returns `{ moved, ended }`.
678
+ */
679
+ function transferSubscriptions({ fromConversationId, toConversationId, relayId = null } = {}) {
680
+ const preview = previewTransfer({ fromConversationId, toConversationId, relayId });
681
+ const from = preview.from.id;
682
+ const to = toText(toConversationId);
683
+ const relay = relayId === null ? null : toText(relayId);
684
+ const ended = [];
685
+ const move = db.transaction(() => {
686
+ if (relay === null) sql.moveSubscriptions.run(to, from);
687
+ else remoteSql.moveSubscriptionsOfRelay.run(to, from, relay);
688
+ for (const subscription of preview.subscriptions) {
689
+ // A paired relay's session ids are never this relay's conversations.
690
+ if (subscription.scope === 'all' || subscription.remoteId) continue;
691
+ const ids = subscription.sessions.map((session) => session.id);
692
+ if (!ids.includes(to)) continue;
693
+ const rest = ids.filter((id) => id !== to);
694
+ if (rest.length) sql.setSessions.run(JSON.stringify(rest), subscription.id);
695
+ else if (endSubscription(subscription.id, 'transferred-empty')) ended.push(subscription.id);
696
+ }
697
+ if (relay === null) sql.moveUndelivered.run(to, from);
698
+ else remoteSql.moveUndeliveredOfRelay.run(to, from, relay);
699
+ sql.dropUndeliveredAbout.run(to, to);
700
+ });
701
+ move();
702
+ const pending = flushTimers.get(from);
703
+ if (pending) clearTimer(pending.timer);
704
+ flushTimers.delete(from);
705
+ for (const subscription of preview.subscriptions) {
706
+ busySinceBySubscription.delete(subscription.id);
707
+ if (!subscription.remoteId) noteBusy(sql.getSubscription.get(subscription.id));
708
+ }
709
+ scheduleFlush(to);
710
+ log(`conv=${shortId(from)} → conv=${shortId(to)}: ${preview.count} subscription${preview.count === 1 ? '' : 's'} transferred`);
711
+ broadcast();
712
+ // The paired relays learn the new watcher's name with the next fetch.
713
+ notifyRemoteChange(preview.subscriptions.filter((sub) => sub.remoteId).map((sub) => sub.relay));
714
+ const moved = preview.subscriptions
715
+ .filter((subscription) => !ended.includes(subscription.id))
716
+ .map((subscription) => shapeSubscription(sql.getSubscription.get(subscription.id)));
717
+ return { from: preview.from, moved, ended };
718
+ }
719
+
720
+ /** Undelivered events of a watcher with no active subscription left go. */
721
+ function dropOrphanedEvents(watchers) {
722
+ const stillWatching = new Set(activeRows().map((row) => row.watcher_conversation_id));
723
+ for (const watcher of watchers) {
724
+ // A paired relay's events wait for its fetch (and the retention bound):
725
+ // its subscription's last events and its end must still reach it.
726
+ if (stillWatching.has(watcher) || isServedKey(watcher)) continue;
727
+ try { sql.dropUndelivered.run(watcher); } catch {}
728
+ const pending = flushTimers.get(watcher);
729
+ if (pending) clearTimer(pending.timer);
730
+ flushTimers.delete(watcher);
731
+ }
732
+ }
733
+
734
+ /** A conversation was archived or deleted: its subscriptions and its place in others end. */
735
+ function noteConversationGone(conversationId, { deleted = false } = {}) {
736
+ if (!available) return;
737
+ const id = toText(conversationId);
738
+ if (!id) return;
739
+ const touched = new Set();
740
+ const remoteRelays = [];
741
+ for (const row of activeRows()) {
742
+ if (row.watcher_conversation_id === id) {
743
+ const reason = deleted ? 'watcher-deleted' : 'watcher-archived';
744
+ if (isMirror(row) ? endMirror(row, reason) : endSubscription(row.id, reason)) touched.add(row.watcher_conversation_id);
745
+ if (isMirror(row)) remoteRelays.push(toText(row.relay_id));
746
+ continue;
747
+ }
748
+ // A mirror lists a paired relay's sessions, never this one's.
749
+ if (toText(row.scope) === 'all' || isMirror(row)) continue;
750
+ const ids = parseJson(row.session_ids_json, []).map(toText);
751
+ if (!ids.includes(id)) continue;
752
+ const rest = ids.filter((sessionId) => sessionId !== id);
753
+ if (rest.length) sql.setSessions.run(JSON.stringify(rest), row.id);
754
+ else endSubscription(row.id, deleted ? 'deleted' : 'archived');
755
+ touched.add(row.watcher_conversation_id);
756
+ }
757
+ tracker?.forget?.(id);
758
+ if (touched.size) {
759
+ log(`conv=${shortId(id)} ${deleted ? 'deleted' : 'archived'}: subscriptions updated`);
760
+ dropOrphanedEvents(touched);
761
+ broadcast();
762
+ notifyRemoteChange(remoteRelays);
763
+ }
764
+ }
765
+
766
+ // ─── Recording ─────────────────────────────────────────────────────────────
767
+
768
+ function subscriptionCovers(row, sessionId) {
769
+ if (row.watcher_conversation_id === sessionId) return false;
770
+ if (toText(row.scope) === 'all') return true;
771
+ return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
772
+ }
773
+
774
+ function wants(row, type) {
775
+ return parseJson(row.events_json, []).includes(type);
776
+ }
777
+
778
+ /** `match` filters the events it can be tried on; the others pass. */
779
+ function passesMatch(row, event) {
780
+ const pattern = toText(row.match) ? row.match : '';
781
+ if (!pattern) return { pass: true, hit: false };
782
+ const text = relayEventMatchText(event);
783
+ if (text === null) return { pass: true, hit: false };
784
+ const compiled = compileRelayEventMatch(pattern);
785
+ if (!compiled.ok || !compiled.regex) return { pass: true, hit: false };
786
+ const hit = compiled.regex.test(text);
787
+ return { pass: hit, hit };
788
+ }
789
+
790
+ function buildEvent(type, sessionId, detail) {
791
+ const session = sessionId ? conversation(sessionId) : null;
792
+ return {
793
+ v: RELAY_EVENT_VERSION,
794
+ relay: { id: RELAY_EVENT_SELF_RELAY_ID, name: toText(relayName()) || null },
795
+ session: sessionId ? { id: sessionId, title: toText(session?.title) || null } : null,
796
+ type,
797
+ at: isoNow(),
798
+ detail: detail && typeof detail === 'object' ? detail : {},
799
+ };
800
+ }
801
+
802
+ /**
803
+ * One event of a session. Every active subscription that covers the
804
+ * session and wants the type gets it, one row per watcher.
805
+ */
806
+ function record(type, sessionId, detail = {}) {
807
+ if (!available || !RELAY_EVENT_TYPES.includes(type)) return 0;
808
+ const id = toText(sessionId);
809
+ if (!id) return 0;
810
+ const rows = localWatchRows().filter((row) => wants(row, type) && subscriptionCovers(row, id));
811
+ if (!rows.length) return 0;
812
+ const event = buildEvent(type, id, detail);
813
+ return enqueueForRows(rows, event, id);
814
+ }
815
+
816
+ function enqueueForRows(rows, event, sessionId) {
817
+ const byWatcher = new Map();
818
+ for (const row of rows) {
819
+ const verdict = passesMatch(row, event);
820
+ if (!verdict.pass) continue;
821
+ const list = byWatcher.get(row.watcher_conversation_id) || [];
822
+ list.push({ row, hit: verdict.hit });
823
+ byWatcher.set(row.watcher_conversation_id, list);
824
+ }
825
+ const urgent = isUrgentRelayEventType(event.type);
826
+ // The stored copy keeps the full reply for a match tried later; the
827
+ // message shows the preview only.
828
+ const stored = { ...event, detail: { ...event.detail } };
829
+ delete stored.detail.replyText;
830
+ let recorded = 0;
831
+ for (const [watcher, matches] of byWatcher) {
832
+ const first = matches[0].row;
833
+ // A one-shot subscription ends with this event; with a match, only
834
+ // with an event that matched.
835
+ const heartbeat = event.type === RELAY_EVENT_HEARTBEAT_TYPE;
836
+ const ending = heartbeat ? [] : matches
837
+ .filter(({ row, hit }) => Number(row.once || 0) === 1 && (!toText(row.match) || hit))
838
+ .map(({ row }) => row.id);
839
+ const payload = { ...stored, subscriptionIds: matches.map(({ row }) => row.id), ...(ending.length ? { ends: ending } : {}) };
840
+ sql.insertEvent.run(first.id, watcher, RELAY_EVENT_SELF_RELAY_ID, sessionId || '', event.type, urgent ? 1 : 0, JSON.stringify(payload), event.at);
841
+ if (!heartbeat) for (const { row } of matches) sql.markFired.run(event.at, row.id);
842
+ for (const subscriptionId of ending) endSubscription(subscriptionId, 'fired');
843
+ if (ending.length) broadcast();
844
+ scheduleFlush(watcher, { urgent });
845
+ recorded += 1;
846
+ }
847
+ return recorded;
848
+ }
849
+
850
+ // ─── Flushing ──────────────────────────────────────────────────────────────
851
+
852
+ function wakesInLastHour(watcher) {
853
+ try {
854
+ return Number(sql.countWakes.get(watcher, new Date(now() - HOUR_MS).toISOString(), RELAY_EVENT_HEARTBEAT_TYPE)?.n || 0);
855
+ } catch {
856
+ return 0;
857
+ }
858
+ }
859
+
860
+ function overCap(watcher) {
861
+ return wakesInLastHour(watcher) >= RELAY_EVENT_LIMITS.wakesPerHour;
862
+ }
863
+
864
+ const isHeartbeatRow = (row) => toText(row.type) === RELAY_EVENT_HEARTBEAT_TYPE;
865
+
866
+ function coalescedDue(rows) {
867
+ const first = Math.min(...rows.map((row) => Date.parse(String(row.created_at)) || now()));
868
+ return Math.max(now(), first + RELAY_EVENT_LIMITS.coalesceMs);
869
+ }
870
+
871
+ function digestDue(watcher) {
872
+ const last = Date.parse(String(sql.lastWake.get(watcher, RELAY_EVENT_HEARTBEAT_TYPE)?.at || '')) || now();
873
+ return Math.max(now(), last + RELAY_EVENT_LIMITS.digestIntervalMs);
874
+ }
875
+
876
+ function dueAtFor(watcher, undelivered) {
877
+ if (undelivered.some((row) => Number(row.urgent) === 1)) return now();
878
+ if (overCap(watcher)) {
879
+ // Heartbeats are not held for the digest: they go after the usual
880
+ // coalescing, alone.
881
+ const heartbeats = undelivered.filter(isHeartbeatRow);
882
+ const others = undelivered.filter((row) => !isHeartbeatRow(row));
883
+ const dues = [];
884
+ if (heartbeats.length) dues.push(coalescedDue(heartbeats));
885
+ if (others.length) dues.push(digestDue(watcher));
886
+ return Math.min(...dues);
887
+ }
888
+ return coalescedDue(undelivered);
889
+ }
890
+
891
+ function scheduleFlush(watcher, { urgent = false } = {}) {
892
+ // A paired relay's events wait for its fetch.
893
+ if (isServedKey(watcher)) return;
894
+ let undelivered = [];
895
+ try {
896
+ undelivered = sql.listUndelivered.all(watcher);
897
+ } catch {
898
+ return;
899
+ }
900
+ if (!undelivered.length) return;
901
+ const dueAt = urgent ? now() : dueAtFor(watcher, undelivered);
902
+ const current = flushTimers.get(watcher);
903
+ if (current && current.dueAt <= dueAt) return;
904
+ if (current) clearTimer(current.timer);
905
+ const delay = Math.max(0, dueAt - now());
906
+ const timer = setTimer(() => {
907
+ flushTimers.delete(watcher);
908
+ try {
909
+ flush(watcher);
910
+ } catch (error) {
911
+ warn(`flush failed conv=${shortId(watcher)}: ${toText(error?.message || error).slice(0, 200)}`);
912
+ }
913
+ }, delay);
914
+ flushTimers.set(watcher, { timer, dueAt });
915
+ }
916
+
917
+ /**
918
+ * Writes the watcher's undelivered events into its conversation now:
919
+ * appended to its event row that is still pending, else as a new message.
920
+ * Returns `{ messageId, merged, digest, count }` or null.
921
+ */
922
+ function flush(watcher) {
923
+ if (!available || isServedKey(watcher)) return null;
924
+ let rows = sql.listUndelivered.all(watcher);
925
+ if (!rows.length) return null;
926
+ const target = conversation(watcher);
927
+ if (!isLive(target)) {
928
+ sql.dropUndelivered.run(watcher);
929
+ return null;
930
+ }
931
+ const urgent = rows.some((row) => Number(row.urgent) === 1);
932
+ const capped = overCap(watcher);
933
+ let digest = false;
934
+ // Past the cap only an urgent event or the digest's time sends all;
935
+ // before that, heartbeats go alone.
936
+ if (capped && !urgent) {
937
+ if (digestDue(watcher) > now()) {
938
+ const heartbeats = rows.filter(isHeartbeatRow);
939
+ if (!heartbeats.length || coalescedDue(heartbeats) > now()) {
940
+ scheduleFlush(watcher);
941
+ return null;
942
+ }
943
+ rows = heartbeats;
944
+ } else {
945
+ digest = rows.some((row) => !isHeartbeatRow(row));
946
+ }
947
+ }
948
+ const events = rows.map((row) => parseJson(row.payload_json, null)).filter(Boolean);
949
+ const ended = [...new Set(events.flatMap((event) => (Array.isArray(event.ends) ? event.ends : [])))];
950
+ const at = isoNow();
951
+ let result = null;
952
+ const write = db.transaction(() => {
953
+ const pending = sql.findPendingEventRow.get(watcher, RELAY_EVENT_MESSAGE_KIND) || null;
954
+ if (pending) {
955
+ const earlier = sql.listEventsOfMessage.all(pending.id).map((row) => parseJson(row.payload_json, null)).filter(Boolean);
956
+ const all = [...earlier, ...events];
957
+ const allEnded = [...new Set(all.flatMap((event) => (Array.isArray(event.ends) ? event.ends : [])))];
958
+ const text = buildRelayEventMessageText(all, { digest, ended: allEnded });
959
+ sql.updateQueueText.run(text, pending.id);
960
+ sql.updateMessageText.run(text, pending.id);
961
+ for (const row of rows) sql.markDelivered.run(at, pending.id, row.id);
962
+ const timestamp = sql.getMessageTimestamp.get(pending.id)?.timestamp || at;
963
+ result = { messageId: String(pending.id), merged: true, digest, count: rows.length, text, timestamp };
964
+ return;
965
+ }
966
+ const messageId = randomUUID();
967
+ const text = buildRelayEventMessageText(events, { digest, ended });
968
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(watcher) || null;
969
+ const latestModel = stmts.getLatestConversationModel?.get?.(watcher)?.model || null;
970
+ // The model the conversation last ran on, as the usage-limit follow-up
971
+ // picks it.
972
+ const model = toText(latestModel) || toText(runtimeSession?.provider_model) || toText(runtimeSession?.model) || null;
973
+ const modelVariant = model;
974
+ const relayMode = toText(target.preferred_relay_mode) || 'agent';
975
+ const owner = toText(runtimeSession?.sdk_session_id) || null;
976
+ stmts.insertMsg.run(
977
+ messageId,
978
+ watcher,
979
+ 'user',
980
+ text,
981
+ modelVariant || null,
982
+ relayMode,
983
+ null,
984
+ at,
985
+ modelVariant || null,
986
+ null,
987
+ toText(modelVariant).toLowerCase() === 'auto' ? 'auto' : 'manual',
988
+ );
989
+ stmts.setMessageKind?.run?.(RELAY_EVENT_MESSAGE_KIND, messageId);
990
+ stmts.updateConvTime?.run?.(at, watcher);
991
+ const queueArgs = [
992
+ messageId,
993
+ watcher,
994
+ runtimeSession?.id || null,
995
+ 0,
996
+ model,
997
+ modelVariant || null,
998
+ toText(target.preferred_reasoning_effort) || null,
999
+ null,
1000
+ relayMode,
1001
+ text,
1002
+ null,
1003
+ at,
1004
+ owner,
1005
+ owner ? at : null,
1006
+ null,
1007
+ null,
1008
+ ];
1009
+ if (stmts.queueHasImageOperationId || stmts.insertQ?.source?.includes?.('image_operation_id')) queueArgs.push(null);
1010
+ stmts.insertQ.run(...queueArgs);
1011
+ for (const row of rows) sql.markDelivered.run(at, messageId, row.id);
1012
+ result = { messageId, merged: false, digest, count: rows.length, text, model: modelVariant, relayMode, owner, timestamp: at };
1013
+ });
1014
+ write();
1015
+ if (!result) return null;
1016
+ // Whatever stayed behind (held for the digest) gets its own timer.
1017
+ scheduleFlush(watcher);
1018
+ if (result.merged) {
1019
+ try {
1020
+ emit('message_updated', {
1021
+ conversationId: watcher,
1022
+ messageId: result.messageId,
1023
+ message: { role: 'user', text: result.text, kind: RELAY_EVENT_MESSAGE_KIND, attachments: [], timestamp: result.timestamp },
1024
+ });
1025
+ } catch {}
1026
+ } else {
1027
+ try {
1028
+ emit('user_message', {
1029
+ conversationId: watcher,
1030
+ messageId: result.messageId,
1031
+ senderClientId: null,
1032
+ message: {
1033
+ role: 'user',
1034
+ text: result.text,
1035
+ model: result.model,
1036
+ mode: result.relayMode,
1037
+ attachments: [],
1038
+ timestamp: result.timestamp,
1039
+ kind: RELAY_EVENT_MESSAGE_KIND,
1040
+ },
1041
+ });
1042
+ } catch {}
1043
+ try { emit('message_status', { messageId: result.messageId, conversationId: watcher, status: 'pending' }); } catch {}
1044
+ try { wakeWatcher({ conversationId: watcher, ownerSessionId: result.owner, messageId: result.messageId }); } catch {}
1045
+ }
1046
+ log(`conv=${shortId(watcher)} ${result.merged ? 'appended' : 'woken with'} ${result.count} event${result.count === 1 ? '' : 's'}${result.digest ? ' (digest)' : ''}`);
1047
+ return result;
1048
+ }
1049
+
1050
+ // ─── Paired relays: the watched side ───────────────────────────────────────
1051
+
1052
+ function requireCross() {
1053
+ if (!crossAvailable) {
1054
+ throw new RelayEventError('unavailable', 'Event subscriptions across relays are not available on this relay right now (its storage is missing; see the server log).');
1055
+ }
1056
+ }
1057
+
1058
+ function subscriberOf(value) {
1059
+ const subscriber = cleanSubscriberRelayId(value);
1060
+ if (!subscriber) throw new RelayEventError('invalid', 'The calling relay sent no relay id (X-OAR-Remote-Origin).');
1061
+ return subscriber;
1062
+ }
1063
+
1064
+ /**
1065
+ * A paired relay's agent subscribes to sessions here. `subscriberRelayId`
1066
+ * is that relay's own id as its request states it; the watcher is a
1067
+ * conversation there (`watcherId`, `watcherTitle`, for display only).
1068
+ * Same rules as subscribe; at most activeSubscriptionsPerWatcher per
1069
+ * watcher and remoteSubscriptionsPerRelay per relay. `dryRun` checks all
1070
+ * of it and stores nothing: the other relay asks its user only then.
1071
+ */
1072
+ function subscribeServed({
1073
+ subscriberRelayId,
1074
+ subscriberRelayName = '',
1075
+ watcherId = '',
1076
+ watcherTitle = '',
1077
+ scope = 'sessions',
1078
+ sessionIds = [],
1079
+ events = null,
1080
+ once = false,
1081
+ match = '',
1082
+ stalledMinutes = null,
1083
+ heartbeatMinutes = null,
1084
+ dryRun = false,
1085
+ } = {}) {
1086
+ if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
1087
+ requireCross();
1088
+ const subscriber = subscriberOf(subscriberRelayId);
1089
+ const watcher = toText(watcherId).slice(0, 200);
1090
+ const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes });
1091
+ const { all, ids, types } = prepared;
1092
+ if (Number(remoteSql.countServedOfWatcher.get(subscriber, watcher)?.n || 0) >= RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher) {
1093
+ throw new RelayEventError('limit', `That conversation already has ${RELAY_EVENT_LIMITS.activeSubscriptionsPerWatcher} subscriptions on this relay; unsubscribe one first.`);
1094
+ }
1095
+ if (Number(remoteSql.countServedActive.get(subscriber)?.n || 0) >= RELAY_EVENT_LIMITS.remoteSubscriptionsPerRelay) {
1096
+ throw new RelayEventError('limit', `Your relay already has ${RELAY_EVENT_LIMITS.remoteSubscriptionsPerRelay} subscriptions on this relay; end some first.`);
1097
+ }
1098
+ if (dryRun) {
1099
+ return {
1100
+ id: null,
1101
+ scope: all ? 'all' : 'sessions',
1102
+ sessions: all ? 'all' : ids.map((sessionId) => ({ id: sessionId, title: toText(conversation(sessionId)?.title) || null })),
1103
+ events: types,
1104
+ once: once === true,
1105
+ ...(prepared.matchText ? { match: prepared.matchText } : {}),
1106
+ stalledMinutes: prepared.stalled,
1107
+ ...(prepared.heartbeat ? { heartbeatMinutes: prepared.heartbeat } : {}),
1108
+ };
1109
+ }
1110
+ const id = `sub_${randomBytes(5).toString('hex')}`;
1111
+ const at = isoNow();
1112
+ remoteSql.insertServed.run(
1113
+ id,
1114
+ servedWatcherKey(subscriber),
1115
+ RELAY_EVENT_SELF_RELAY_ID,
1116
+ all ? 'all' : 'sessions',
1117
+ all ? null : JSON.stringify(ids),
1118
+ JSON.stringify(types),
1119
+ once ? 1 : 0,
1120
+ prepared.matchText,
1121
+ prepared.stalled,
1122
+ prepared.heartbeat,
1123
+ at,
1124
+ subscriber,
1125
+ toText(subscriberRelayName).slice(0, 60) || null,
1126
+ watcher || null,
1127
+ toText(watcherTitle).slice(0, 200) || null,
1128
+ at,
1129
+ );
1130
+ const row = sql.getSubscription.get(id);
1131
+ noteBusy(row);
1132
+ log(`relay ${shortId(subscriber)} subscribed ${id} to ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${types.join(',')})${once ? ' once' : ''}`);
1133
+ broadcast();
1134
+ return shapeSubscription(row);
1135
+ }
1136
+
1137
+ /** What a paired relay is told about one of its subscriptions here. */
1138
+ function servedState(row, subscriber, id) {
1139
+ if (!row || toText(row.subscriber_relay_id) !== subscriber) return { id, active: false, endReason: 'unknown' };
1140
+ const shaped = shapeSubscription(row);
1141
+ return {
1142
+ id,
1143
+ active: !row.ended_at,
1144
+ ...(row.ended_at ? { endedAt: row.ended_at, endReason: row.end_reason || null } : {}),
1145
+ scope: shaped.scope,
1146
+ sessions: shaped.sessions,
1147
+ };
1148
+ }
1149
+
1150
+ /**
1151
+ * A paired relay fetches its events. Everything up to `after` (the last
1152
+ * event id it stored) is acknowledged and deleted here; `ended` are
1153
+ * subscriptions it ended (unsubscribe, its watcher archived), applied now;
1154
+ * `subscriptions` lists the ones it still holds, with their watcher, and
1155
+ * gets their state back. Returns `{ events, cursor, more, subscriptions,
1156
+ * ended, dropped }`: at most remotePullBatch events after `after`, oldest
1157
+ * first; `dropped` counts per subscription what was not kept since the
1158
+ * last fetch.
1159
+ */
1160
+ function pullServed({ subscriberRelayId, after = 0, subscriptions = [], ended = [] } = {}) {
1161
+ if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now.');
1162
+ requireCross();
1163
+ const subscriber = subscriberOf(subscriberRelayId);
1164
+ const key = servedWatcherKey(subscriber);
1165
+ const ack = Math.max(0, Math.trunc(Number(after) || 0));
1166
+ const listed = (Array.isArray(subscriptions) ? subscriptions : [])
1167
+ .filter((entry) => entry && typeof entry === 'object')
1168
+ .slice(0, RELAY_EVENT_LIMITS.remoteSubscriptionsPerRelay)
1169
+ .map((entry) => ({ id: toText(entry.id), watcher: entry.watcher && typeof entry.watcher === 'object' ? entry.watcher : null }))
1170
+ .filter((entry) => entry.id);
1171
+ const endedIds = [...new Set((Array.isArray(ended) ? ended : []).map(toText).filter(Boolean))].slice(0, RELAY_EVENT_LIMITS.remoteSubscriptionsPerRelay);
1172
+ const at = isoNow();
1173
+ let result = null;
1174
+ let changed = false;
1175
+ db.transaction(() => {
1176
+ if (ack > 0) remoteSql.deleteServedThrough.run(key, ack);
1177
+ const acked = [];
1178
+ for (const id of endedIds) {
1179
+ const row = sql.getSubscription.get(id);
1180
+ if (row && toText(row.subscriber_relay_id) === subscriber && !row.ended_at && endSubscription(id, 'unsubscribed')) changed = true;
1181
+ // Unknown or someone else's: nothing to end here either.
1182
+ acked.push(id);
1183
+ }
1184
+ for (const entry of listed) {
1185
+ const row = sql.getSubscription.get(entry.id);
1186
+ if (!row || toText(row.subscriber_relay_id) !== subscriber || row.ended_at || !entry.watcher) continue;
1187
+ const watcherId = toText(entry.watcher.conversationId).slice(0, 200) || null;
1188
+ const watcherTitle = toText(entry.watcher.title).slice(0, 200) || null;
1189
+ if (watcherId !== (row.subscriber_watcher_id || null) || watcherTitle !== (row.subscriber_watcher_title || null)) {
1190
+ remoteSql.setServedWatcher.run(watcherId, watcherTitle, entry.id);
1191
+ changed = true;
1192
+ }
1193
+ }
1194
+ remoteSql.touchServed.run(at, subscriber);
1195
+ const batch = RELAY_EVENT_LIMITS.remotePullBatch;
1196
+ const rows = remoteSql.listServedEvents.all(key, ack, batch + 1);
1197
+ const page = rows.slice(0, batch);
1198
+ const dropped = {};
1199
+ for (const entry of listed) {
1200
+ const row = sql.getSubscription.get(entry.id);
1201
+ if (!row || toText(row.subscriber_relay_id) !== subscriber || !(Number(row.dropped_count) > 0)) continue;
1202
+ dropped[entry.id] = Number(row.dropped_count);
1203
+ remoteSql.takeDropped.run(entry.id);
1204
+ }
1205
+ result = {
1206
+ events: page.map((row) => ({
1207
+ id: Number(row.id),
1208
+ type: row.type,
1209
+ urgent: Number(row.urgent) === 1,
1210
+ createdAt: row.created_at,
1211
+ payload: parseJson(row.payload_json, null),
1212
+ })),
1213
+ cursor: page.length ? Number(page.at(-1).id) : ack,
1214
+ more: rows.length > batch,
1215
+ subscriptions: listed.map((entry) => servedState(sql.getSubscription.get(entry.id), subscriber, entry.id)),
1216
+ ended: acked,
1217
+ dropped,
1218
+ };
1219
+ })();
1220
+ if (changed) broadcast();
1221
+ return result;
1222
+ }
1223
+
1224
+ /**
1225
+ * The retention bound of what paired relays have not fetched: per
1226
+ * subscription the newest remoteKeepEvents (questions and errors kept
1227
+ * longest), nothing older than remoteKeepDays; a subscription not fetched
1228
+ * for remoteKeepDays ends ('subscriber-gone'). On the watching side an end
1229
+ * not confirmed for as long is no longer sent: that relay ended it.
1230
+ */
1231
+ function pruneServed() {
1232
+ if (!crossAvailable) return;
1233
+ const cutoff = new Date(now() - RELAY_EVENT_LIMITS.remoteKeepDays * DAY_MS).toISOString();
1234
+ let ended = 0;
1235
+ db.transaction(() => {
1236
+ for (const row of remoteSql.countServedExpired.all(cutoff)) remoteSql.addDropped.run(Number(row.n) || 0, row.subscription_id);
1237
+ remoteSql.deleteServedExpired.run(cutoff);
1238
+ for (const row of remoteSql.listServedOverfull.all(RELAY_EVENT_LIMITS.remoteKeepEvents)) {
1239
+ const extra = Number(row.n) - RELAY_EVENT_LIMITS.remoteKeepEvents;
1240
+ remoteSql.deleteServedOldest.run(row.subscription_id, extra);
1241
+ remoteSql.addDropped.run(extra, row.subscription_id);
1242
+ }
1243
+ for (const row of remoteSql.listServedStale.all(cutoff)) {
1244
+ if (endSubscription(row.id, 'subscriber-gone')) ended += 1;
1245
+ }
1246
+ remoteSql.clearStaleSync.run(cutoff);
1247
+ })();
1248
+ if (ended) {
1249
+ log(`${ended} subscription${ended === 1 ? '' : 's'} of paired relays ended: not fetched for ${RELAY_EVENT_LIMITS.remoteKeepDays} days`);
1250
+ broadcast();
1251
+ }
1252
+ }
1253
+
1254
+ // ─── Paired relays: the watching side ──────────────────────────────────────
1255
+
1256
+ /**
1257
+ * Stores this relay's side of a subscription a paired relay accepted:
1258
+ * `remote` is that relay's subscription (id, scope, sessions with titles,
1259
+ * events, …). Returns the subscription as list_subscriptions shows it.
1260
+ */
1261
+ function addMirror({ watcherConversationId, relayId, relayName = '', remote } = {}) {
1262
+ if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now.');
1263
+ requireCross();
1264
+ const watcher = toText(watcherConversationId);
1265
+ const relay = toText(relayId);
1266
+ const remoteId = toText(remote?.id);
1267
+ if (!relay || relay === RELAY_EVENT_SELF_RELAY_ID || !remoteId) throw new RelayEventError('invalid', 'The paired relay did not return a subscription.');
1268
+ if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
1269
+ checkWatcherCapacity(watcher);
1270
+ const all = remote.scope === 'all';
1271
+ const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
1272
+ .map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
1273
+ .filter((session) => session.id);
1274
+ const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_TYPES.includes(type));
1275
+ const id = `sub_${randomBytes(5).toString('hex')}`;
1276
+ const at = isoNow();
1277
+ remoteSql.insertMirror.run(
1278
+ id,
1279
+ watcher,
1280
+ relay,
1281
+ all ? 'all' : 'sessions',
1282
+ all ? null : JSON.stringify(sessions.map((session) => session.id)),
1283
+ JSON.stringify(types.length ? types : [...RELAY_EVENT_DEFAULT_TYPES]),
1284
+ remote.once === true ? 1 : 0,
1285
+ toText(remote.match) ? String(remote.match) : null,
1286
+ Number(remote.stalledMinutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
1287
+ Number(remote.heartbeatMinutes) > 0 ? Number(remote.heartbeatMinutes) : null,
1288
+ at,
1289
+ remoteId,
1290
+ JSON.stringify({ relayName: toText(relayName) || null, sessions }),
1291
+ );
1292
+ noteRemoteLinkOk(relay);
1293
+ log(`conv=${shortId(watcher)} subscribed ${id} on a paired relay (${remoteId} there)`);
1294
+ broadcast();
1295
+ return shapeSubscription(sql.getSubscription.get(id));
1296
+ }
1297
+
1298
+ function mirrorsOf(relayId) {
1299
+ if (!crossAvailable) return [];
1300
+ try {
1301
+ return remoteSql.listMirrorsOfRelay.all(toText(relayId));
1302
+ } catch {
1303
+ return [];
1304
+ }
1305
+ }
1306
+
1307
+ /** The paired relays to fetch from: active mirrors, or ends not delivered yet. */
1308
+ function remoteRelaysToPoll() {
1309
+ if (!crossAvailable) return [];
1310
+ try {
1311
+ return remoteSql.listRelaysToPoll.all().map((row) => toText(row.relay_id)).filter(Boolean);
1312
+ } catch {
1313
+ return [];
1314
+ }
1315
+ }
1316
+
1317
+ /** The watchers with active subscriptions on a paired relay, with one of them each. */
1318
+ function remoteWatchers(relayId) {
1319
+ const watchers = new Map();
1320
+ for (const row of mirrorsOf(relayId)) {
1321
+ if (row.ended_at || watchers.has(row.watcher_conversation_id)) continue;
1322
+ watchers.set(row.watcher_conversation_id, row.id);
1323
+ }
1324
+ return watchers;
1325
+ }
1326
+
1327
+ /** The body of the next fetch from a paired relay. */
1328
+ function buildRemotePull(relayId) {
1329
+ const rows = mirrorsOf(relayId);
1330
+ return {
1331
+ after: Number(getRemoteLink(relayId).cursor) || 0,
1332
+ subscriptions: rows.filter((row) => !row.ended_at).map((row) => ({
1333
+ id: row.remote_subscription_id,
1334
+ watcher: { conversationId: row.watcher_conversation_id, title: toText(conversation(row.watcher_conversation_id)?.title) || null },
1335
+ })),
1336
+ ended: rows.filter((row) => row.ended_at && toText(row.remote_sync) === 'unsubscribe').map((row) => row.remote_subscription_id),
1337
+ };
1338
+ }
1339
+
1340
+ // What of a paired relay's event detail is stored here, by field: nothing
1341
+ // else of its payload is. The formatter one-lines every field; the
1342
+ // allow-list keeps a payload from carrying anything the formatter never
1343
+ // saw (an unknown key, a nested object).
1344
+ const REMOTE_DETAIL_FIELDS = Object.freeze({
1345
+ kind: 'text',
1346
+ message: 'text',
1347
+ messageId: 'text',
1348
+ status: 'text',
1349
+ preview: 'text',
1350
+ prompt: 'text',
1351
+ questionId: 'text',
1352
+ approvalCard: 'bool',
1353
+ description: 'text',
1354
+ summary: 'text',
1355
+ taskId: 'text',
1356
+ exitCode: 'int',
1357
+ statusReported: 'bool',
1358
+ quietMinutes: 'int',
1359
+ what: 'text',
1360
+ since: 'text',
1361
+ scope: 'text',
1362
+ sessionCount: 'int',
1363
+ subscriptionId: 'text',
1364
+ options: 'textList',
1365
+ busy: 'busyList',
1366
+ });
1367
+ const REMOTE_TEXT_MAX = 2000;
1368
+
1369
+ function remoteText(value, max = REMOTE_TEXT_MAX) {
1370
+ return value === null || value === undefined ? '' : String(value).slice(0, max);
1371
+ }
1372
+
1373
+ function cleanRemoteDetail(detail) {
1374
+ const source = detail && typeof detail === 'object' && !Array.isArray(detail) ? detail : {};
1375
+ const clean = {};
1376
+ for (const [key, shape] of Object.entries(REMOTE_DETAIL_FIELDS)) {
1377
+ if (!(key in source)) continue;
1378
+ const value = source[key];
1379
+ if (value === null || value === undefined) continue;
1380
+ if (shape === 'text') {
1381
+ if (typeof value === 'string' || typeof value === 'number') clean[key] = remoteText(value);
1382
+ } else if (shape === 'bool') {
1383
+ if (typeof value === 'boolean') clean[key] = value;
1384
+ } else if (shape === 'int') {
1385
+ if (Number.isInteger(value)) clean[key] = value;
1386
+ } else if (shape === 'textList') {
1387
+ if (Array.isArray(value)) clean[key] = value.slice(0, 50).filter((item) => typeof item === 'string' || typeof item === 'number').map((item) => remoteText(item, 200));
1388
+ } else if (shape === 'busyList') {
1389
+ if (Array.isArray(value)) {
1390
+ clean[key] = value.slice(0, 10)
1391
+ .filter((entry) => entry && typeof entry === 'object')
1392
+ .map((entry) => ({ id: remoteText(entry.id, 200), title: remoteText(entry.title, 200) || null, what: remoteText(entry.what, 40) }));
1393
+ }
1394
+ }
1395
+ }
1396
+ return clean;
1397
+ }
1398
+
1399
+ function remoteIso(value) {
1400
+ const parsed = Date.parse(remoteText(value, 40));
1401
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
1402
+ }
1403
+
1404
+ // How an end on the paired relay reads here.
1405
+ const REMOTE_END_REASONS = Object.freeze({
1406
+ 'unsubscribed-by-user': 'ended-on-relay',
1407
+ unknown: 'gone-on-relay',
1408
+ 'subscriber-gone': 'expired-on-relay',
1409
+ });
1410
+
1411
+ /**
1412
+ * Writes what a fetch from a paired relay brought: its events become this
1413
+ * relay's undelivered events of the watchers whose mirrors they name (an
1414
+ * event for a subscription this relay does not hold is dropped), ends and
1415
+ * session changes there end or update the mirrors, confirmed ends clear
1416
+ * their sync mark, and the cursor moves, all in one transaction. With
1417
+ * `replay` (the first fetch after a gap) events are marked replayed and
1418
+ * urgent and nothing is flushed yet: noteRemoteLinkRestored does that.
1419
+ * Returns per watcher `{ kept, urgent, dropped, ended }`.
1420
+ */
1421
+ function applyRemotePull(relayId, { relayName = '', response = {}, replay = false } = {}) {
1422
+ requireCross();
1423
+ const relay = toText(relayId);
1424
+ const name = toText(relayName) || relay;
1425
+ const rows = mirrorsOf(relay);
1426
+ const byRemote = new Map(rows.map((row) => [row.remote_subscription_id, row]));
1427
+ const at = isoNow();
1428
+ const touched = new Map();
1429
+ const tally = (watcher) => {
1430
+ if (!touched.has(watcher)) touched.set(watcher, { kept: 0, urgent: false, dropped: 0, ended: [] });
1431
+ return touched.get(watcher);
1432
+ };
1433
+ let changed = false;
1434
+ db.transaction(() => {
1435
+ for (const item of Array.isArray(response.events) ? response.events : []) {
1436
+ const payload = item?.payload && typeof item.payload === 'object' ? item.payload : null;
1437
+ if (!payload || !isRelayEventType(payload.type)) continue;
1438
+ const named = (Array.isArray(payload.subscriptionIds) ? payload.subscriptionIds : []).map(toText);
1439
+ const groups = new Map();
1440
+ for (const remoteId of named) {
1441
+ const row = byRemote.get(remoteId);
1442
+ if (!row || row.ended_at) continue;
1443
+ if (!groups.has(row.watcher_conversation_id)) groups.set(row.watcher_conversation_id, []);
1444
+ groups.get(row.watcher_conversation_id).push(row);
1445
+ }
1446
+ for (const [watcher, mine] of groups) {
1447
+ const endingRemote = new Set((Array.isArray(payload.ends) ? payload.ends : []).map(toText));
1448
+ const ends = mine.filter((row) => endingRemote.has(row.remote_subscription_id)).map((row) => row.id);
1449
+ // The stored event is built from the allow-listed fields of the
1450
+ // payload, never from the payload itself.
1451
+ const detail = cleanRemoteDetail(payload.detail);
1452
+ // all_idle and heartbeat name their subscription: this relay's id for it.
1453
+ const named = mine.find((row) => row.remote_subscription_id === toText(detail.subscriptionId));
1454
+ if (named) detail.subscriptionId = named.id;
1455
+ else delete detail.subscriptionId;
1456
+ const sessionId = payload.session && typeof payload.session === 'object' ? remoteText(payload.session.id, 200) : '';
1457
+ const event = {
1458
+ v: RELAY_EVENT_VERSION,
1459
+ relay: { id: relay, name },
1460
+ session: sessionId ? { id: sessionId, title: remoteText(payload.session.title, 200) || null } : null,
1461
+ type: toText(payload.type),
1462
+ at: remoteIso(payload.at) || at,
1463
+ detail,
1464
+ subscriptionIds: mine.map((row) => row.id),
1465
+ ...(ends.length ? { ends } : {}),
1466
+ ...(replay ? { replayed: true } : {}),
1467
+ };
1468
+ const urgent = replay || item.urgent === true || isUrgentRelayEventType(event.type);
1469
+ sql.insertEvent.run(mine[0].id, watcher, relay, toText(event.session?.id), event.type, urgent ? 1 : 0, JSON.stringify(event), at);
1470
+ if (event.type !== RELAY_EVENT_HEARTBEAT_TYPE) for (const row of mine) sql.markFired.run(at, row.id);
1471
+ for (const id of ends) {
1472
+ if (endSubscription(id, 'fired')) changed = true;
1473
+ }
1474
+ const entry = tally(watcher);
1475
+ entry.kept += 1;
1476
+ entry.urgent = entry.urgent || urgent;
1477
+ }
1478
+ }
1479
+ for (const state of Array.isArray(response.subscriptions) ? response.subscriptions : []) {
1480
+ const row = byRemote.get(toText(state?.id));
1481
+ if (!row || row.ended_at) continue;
1482
+ if (state.active === false) {
1483
+ const reason = REMOTE_END_REASONS[toText(state.endReason)] || toText(state.endReason) || 'gone-on-relay';
1484
+ if (endSubscription(row.id, reason)) {
1485
+ tally(row.watcher_conversation_id).ended.push(row.id);
1486
+ changed = true;
1487
+ }
1488
+ continue;
1489
+ }
1490
+ if (state.scope === 'sessions' && Array.isArray(state.sessions)) {
1491
+ const sessions = state.sessions.map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null })).filter((session) => session.id);
1492
+ const meta = { ...parseJson(row.remote_meta_json, {}), relayName: name, sessions };
1493
+ const ids = JSON.stringify(sessions.map((session) => session.id));
1494
+ if (ids !== row.session_ids_json || JSON.stringify(meta) !== row.remote_meta_json) {
1495
+ remoteSql.setMirrorSessions.run(ids, JSON.stringify(meta), row.id);
1496
+ changed = true;
1497
+ }
1498
+ }
1499
+ }
1500
+ for (const remoteId of Array.isArray(response.ended) ? response.ended : []) {
1501
+ const row = byRemote.get(toText(remoteId));
1502
+ if (row && row.remote_sync) remoteSql.setRemoteSync.run(null, row.id);
1503
+ }
1504
+ for (const [remoteId, count] of Object.entries(response.dropped && typeof response.dropped === 'object' ? response.dropped : {})) {
1505
+ const row = byRemote.get(toText(remoteId));
1506
+ if (row && Number(count) > 0) tally(row.watcher_conversation_id).dropped += Number(count);
1507
+ }
1508
+ remoteSql.linkOk.run(relay, Math.max(0, Math.trunc(Number(response.cursor) || 0)), at);
1509
+ })();
1510
+ if (!replay) {
1511
+ for (const [watcher, entry] of touched) if (entry.kept) scheduleFlush(watcher, { urgent: entry.urgent });
1512
+ }
1513
+ if (changed) broadcast();
1514
+ return touched;
1515
+ }
1516
+
1517
+ /**
1518
+ * The first fetch after a gap is done (every page of it): each watcher with
1519
+ * subscriptions on that relay gets link_lost and link_restored around what
1520
+ * was kept, and its events go out at once, as one row.
1521
+ * `perWatcher`: watcher → `{ subscriptionId, kept, dropped, ended }`.
1522
+ */
1523
+ function noteRemoteLinkRestored(relayId, { relayName = '', since = null, perWatcher = new Map() } = {}) {
1524
+ requireCross();
1525
+ const relay = toText(relayId);
1526
+ const at = isoNow();
1527
+ const sinceText = toText(since) || null;
1528
+ db.transaction(() => {
1529
+ for (const [watcher, info] of perWatcher) {
1530
+ if (!isLive(conversation(watcher))) continue;
1531
+ const base = { v: RELAY_EVENT_VERSION, relay: { id: relay, name: toText(relayName) || relay }, session: null, at, subscriptionIds: [info.subscriptionId] };
1532
+ const lost = { ...base, type: RELAY_EVENT_LINK_LOST_TYPE, detail: { since: sinceText } };
1533
+ const restored = {
1534
+ ...base,
1535
+ type: RELAY_EVENT_LINK_RESTORED_TYPE,
1536
+ detail: { since: sinceText, kept: Number(info.kept) || 0, dropped: Number(info.dropped) || 0, ended: info.ended || [] },
1537
+ };
1538
+ sql.insertEvent.run(info.subscriptionId, watcher, relay, '', lost.type, 1, JSON.stringify(lost), at);
1539
+ sql.insertEvent.run(info.subscriptionId, watcher, relay, '', restored.type, 1, JSON.stringify(restored), at);
1540
+ }
1541
+ })();
1542
+ for (const watcher of perWatcher.keys()) scheduleFlush(watcher, { urgent: true });
1543
+ const kept = [...perWatcher.values()].reduce((sum, info) => sum + (Number(info.kept) || 0), 0);
1544
+ log(`link to paired relay ${shortId(relay)} restored (no answer since ${sinceText || '?'}): ${kept} kept event${kept === 1 ? '' : 's'} replayed to ${perWatcher.size} watcher${perWatcher.size === 1 ? '' : 's'}`);
1545
+ }
1546
+
1547
+ /** The link to a paired relay: `{ cursor, last_ok_at, first_fail_at, lost_at, last_error }`. */
1548
+ function getRemoteLink(relayId) {
1549
+ const empty = { relay_id: toText(relayId), cursor: 0, last_ok_at: null, first_fail_at: null, lost_at: null, last_error: null };
1550
+ if (!crossAvailable) return empty;
1551
+ try {
1552
+ return remoteSql.getLink.get(toText(relayId)) || empty;
1553
+ } catch {
1554
+ return empty;
1555
+ }
1556
+ }
1557
+
1558
+ /** A call to the paired relay succeeded (subscribe): the link is up. */
1559
+ function noteRemoteLinkOk(relayId) {
1560
+ if (!crossAvailable) return;
1561
+ try { remoteSql.linkOk.run(toText(relayId), 0, isoNow()); } catch {}
1562
+ }
1563
+
1564
+ /**
1565
+ * A fetch failed. Once nothing answered for remoteGapMs since the last
1566
+ * success, the link counts as lost from that success on.
1567
+ */
1568
+ function noteRemoteLinkFailure(relayId, error = '') {
1569
+ if (!crossAvailable) return getRemoteLink(relayId);
1570
+ const relay = toText(relayId);
1571
+ try {
1572
+ remoteSql.linkFailed.run(relay, isoNow(), toText(error).slice(0, 300) || null);
1573
+ const link = getRemoteLink(relay);
1574
+ const lastOk = Date.parse(String(link.last_ok_at || ''));
1575
+ if (!link.lost_at && Number.isFinite(lastOk) && now() - lastOk >= RELAY_EVENT_LIMITS.remoteGapMs) {
1576
+ remoteSql.linkLost.run(link.last_ok_at, relay);
1577
+ log(`link to paired relay ${shortId(relay)} lost (no answer since ${link.last_ok_at})`);
1578
+ }
1579
+ } catch {}
1580
+ return getRemoteLink(relay);
1581
+ }
1582
+
1583
+ /** The pairing with a paired relay is gone: its mirrors end, nothing is left to deliver. */
1584
+ function noteRelayRemoved(relayId) {
1585
+ if (!crossAvailable) return [];
1586
+ const relay = toText(relayId);
1587
+ const ended = [];
1588
+ db.transaction(() => {
1589
+ for (const row of mirrorsOf(relay)) {
1590
+ if (!row.ended_at && endSubscription(row.id, 'pairing-removed')) ended.push(row.id);
1591
+ }
1592
+ remoteSql.clearRelaySync.run(relay);
1593
+ remoteSql.deleteLink.run(relay);
1594
+ })();
1595
+ if (ended.length) {
1596
+ log(`pairing removed: ${ended.length} subscription${ended.length === 1 ? '' : 's'} on it ended`);
1597
+ broadcast();
1598
+ }
1599
+ return ended;
1600
+ }
1601
+
1602
+ // ─── Hooks ─────────────────────────────────────────────────────────────────
1603
+
1604
+ function isRelayEventTurn(queueMessageId) {
1605
+ const id = toText(queueMessageId);
1606
+ if (!id) return false;
1607
+ try {
1608
+ return toText(sql.getQueueMessageKind.get(id)?.kind) === RELAY_EVENT_MESSAGE_KIND;
1609
+ } catch {
1610
+ return false;
1611
+ }
1612
+ }
1613
+
1614
+ /** Looks at all_idle again in a moment (one timer for every subscription). */
1615
+ function scheduleAllIdleCheck(delayMs = 1_000) {
1616
+ if (!available || !started || allIdleTimer) return;
1617
+ allIdleTimer = setTimer(() => {
1618
+ allIdleTimer = null;
1619
+ try { evaluateAllIdle(); } catch (error) { warn(`all_idle check failed: ${toText(error?.message || error).slice(0, 200)}`); }
1620
+ }, delayMs);
1621
+ }
1622
+
1623
+ /** A watched session's turn or tasks changed. */
1624
+ function afterStateChange(conversationId) {
1625
+ void conversationId;
1626
+ scheduleAllIdleCheck();
1627
+ }
1628
+
1629
+ /** A turn ended with a reply (`status` done or stopped). */
1630
+ function noteTurnEnd({ conversationId, queueMessageId = '', replyText = '', status = 'done' } = {}) {
1631
+ if (!available) return;
1632
+ // A turn that answered relay events tells no other watcher: two
1633
+ // watchers of each other would otherwise wake each other for good.
1634
+ if (!isRelayEventTurn(queueMessageId)) {
1635
+ const text = String(replyText || '');
1636
+ record('turn_end', conversationId, {
1637
+ status,
1638
+ preview: text.slice(0, RELAY_EVENT_LIMITS.previewChars * 2),
1639
+ replyText: text.slice(0, 20_000),
1640
+ ...(queueMessageId ? { messageId: toText(queueMessageId) } : {}),
1641
+ });
1642
+ }
1643
+ afterStateChange(conversationId);
1644
+ }
1645
+
1646
+ /** A turn or the session failed: `kind` turn_failed, usage_limit, worker_died. */
1647
+ function noteError({ conversationId, kind = 'turn_failed', message = '', queueMessageId = '' } = {}) {
1648
+ if (!available) return;
1649
+ record('error', conversationId, {
1650
+ kind,
1651
+ message: String(message || '').slice(0, 1000),
1652
+ ...(queueMessageId ? { messageId: toText(queueMessageId) } : {}),
1653
+ });
1654
+ afterStateChange(conversationId);
1655
+ }
1656
+
1657
+ /** A question card was put on a session's turn (formatted question row). */
1658
+ function noteQuestion(question) {
1659
+ if (!available || !question) return;
1660
+ const conversationId = toText(question.conversationId || question.conversation_id);
1661
+ let choices = question.choices;
1662
+ if (typeof choices === 'string') choices = parseJson(choices, []);
1663
+ const options = (Array.isArray(choices) ? choices : [])
1664
+ .map((choice) => (choice && typeof choice === 'object' ? toText(choice.label || choice.value) : toText(choice)))
1665
+ .filter(Boolean);
1666
+ const context = question.context && typeof question.context === 'object'
1667
+ ? question.context
1668
+ : (parseJson(question.request, {})?.context || {});
1669
+ record('question_asked', conversationId, {
1670
+ prompt: String(question.prompt || question.question || '').slice(0, 2000),
1671
+ options,
1672
+ questionId: toText(question.id),
1673
+ ...(toText(context?.source) === 'remote_relay' ? { approvalCard: true } : {}),
1674
+ });
1675
+ }
1676
+
1677
+ /** The tracker's edge: a background task started or ended. */
1678
+ function noteTaskEdge({ type, conversationId, task } = {}) {
1679
+ if (!available || !task) return;
1680
+ if (type === 'task_started' || type === 'task_finished' || type === 'task_failed') {
1681
+ record(type, conversationId, {
1682
+ kind: task.kind,
1683
+ description: task.description,
1684
+ status: task.status,
1685
+ ...(Number.isInteger(task.exitCode) ? { exitCode: task.exitCode } : {}),
1686
+ ...(task.statusReported === false ? { statusReported: false } : {}),
1687
+ ...(task.summary ? { summary: task.summary } : {}),
1688
+ ...(task.id ? { taskId: task.id } : {}),
1689
+ });
1690
+ }
1691
+ afterStateChange(conversationId);
1692
+ }
1693
+
1694
+ // ─── Sweeps: stalled and all_idle ──────────────────────────────────────────
1695
+
1696
+ function describe(conversationId) {
1697
+ try {
1698
+ return tracker?.describe?.(conversationId) || { state: 'idle', tasks: [], runningTaskCount: 0, lastActivityAt: null };
1699
+ } catch {
1700
+ return { state: 'idle', tasks: [], runningTaskCount: 0, lastActivityAt: null };
1701
+ }
1702
+ }
1703
+
1704
+ function bootReporting() {
1705
+ try {
1706
+ return tracker?.bootReporting?.() || { active: false, remainingMs: 0 };
1707
+ } catch {
1708
+ return { active: false, remainingMs: 0 };
1709
+ }
1710
+ }
1711
+
1712
+ /** Busy for events: a turn other than one answering events, or a running task. */
1713
+ function isSessionBusy(conversationId) {
1714
+ const view = describe(conversationId);
1715
+ if (view.runningTaskCount > 0) return true;
1716
+ if (view.state !== 'running_turn') return false;
1717
+ try {
1718
+ return Number(sql.countNonEventTurns.get(conversationId, RELAY_EVENT_MESSAGE_KIND)?.n || 0) > 0;
1719
+ } catch {
1720
+ return true;
1721
+ }
1722
+ }
1723
+
1724
+ function candidateSessions(row) {
1725
+ if (toText(row.scope) !== 'all') {
1726
+ return parseJson(row.session_ids_json, []).map(toText).filter((id) => id && id !== row.watcher_conversation_id);
1727
+ }
1728
+ const ids = new Set();
1729
+ try { for (const r of sql.listBusyConversations.all()) ids.add(toText(r.conversation_id)); } catch {}
1730
+ try { for (const r of sql.listRunningRunConversations.all()) ids.add(toText(r.conversation_id)); } catch {}
1731
+ for (const id of tracker?.knownConversationIds?.() || []) ids.add(toText(id));
1732
+ ids.delete(row.watcher_conversation_id);
1733
+ return [...ids].filter((id) => id && isLive(conversation(id)));
1734
+ }
1735
+
1736
+ function isWatchedSetBusy(row) {
1737
+ if (!row) return false;
1738
+ return candidateSessions(row).some(isSessionBusy);
1739
+ }
1740
+
1741
+ /** Where a subscription starts from (subscribe, transfer, boot): busy now or not. */
1742
+ function noteBusy(row, busy = isWatchedSetBusy(row)) {
1743
+ if (!row) return false;
1744
+ busyBySubscription.set(row.id, busy);
1745
+ idleSinceBySubscription.delete(row.id);
1746
+ noteHeartbeatBusy(row, busy);
1747
+ return busy;
1748
+ }
1749
+
1750
+ function noteHeartbeatBusy(row, busy) {
1751
+ if (busy && !busySinceBySubscription.has(row.id)) busySinceBySubscription.set(row.id, now());
1752
+ if (!busy) busySinceBySubscription.delete(row.id);
1753
+ }
1754
+
1755
+ function busyEntries(row) {
1756
+ return candidateSessions(row).filter(isSessionBusy).map((sessionId) => {
1757
+ const view = describe(sessionId);
1758
+ const what = view.state === 'running_turn'
1759
+ ? 'turn'
1760
+ : `${view.runningTaskCount} task${view.runningTaskCount === 1 ? '' : 's'}`;
1761
+ return { id: sessionId, title: toText(conversation(sessionId)?.title) || null, what };
1762
+ });
1763
+ }
1764
+
1765
+ function evaluateHeartbeats() {
1766
+ const at = now();
1767
+ for (const row of localWatchRows()) {
1768
+ const minutes = Number(row.heartbeat_minutes);
1769
+ if (!(minutes > 0)) continue;
1770
+ const busy = busyEntries(row);
1771
+ noteHeartbeatBusy(row, busy.length > 0);
1772
+ if (!busy.length) continue;
1773
+ const since = Math.max(busySinceBySubscription.get(row.id) || at, Date.parse(String(row.last_heartbeat_at || '')) || 0);
1774
+ if (at - since < minutes * 60_000) continue;
1775
+ sql.markHeartbeat.run(new Date(at).toISOString(), row.id);
1776
+ const count = toText(row.scope) === 'all' ? null : parseJson(row.session_ids_json, []).length;
1777
+ const event = buildEvent(RELAY_EVENT_HEARTBEAT_TYPE, '', { scope: toText(row.scope), sessionCount: count, subscriptionId: row.id, busy });
1778
+ enqueueForRows([row], event, '');
1779
+ }
1780
+ }
1781
+
1782
+ /**
1783
+ * all_idle: a watched set that was busy and now looks idle twice, at least
1784
+ * ALL_IDLE_SETTLE_MS apart, fires once; work in between starts over.
1785
+ */
1786
+ function evaluateAllIdle() {
1787
+ const at = now();
1788
+ for (const row of localWatchRows()) {
1789
+ const busy = isWatchedSetBusy(row);
1790
+ if (busy) {
1791
+ busyBySubscription.set(row.id, true);
1792
+ idleSinceBySubscription.delete(row.id);
1793
+ continue;
1794
+ }
1795
+ if (busyBySubscription.get(row.id) !== true) continue;
1796
+ // A runtime that only just started may not have heard yet from the
1797
+ // workers that outlived the one before it: a set that looks idle now
1798
+ // is not known to be idle, so the look does not count.
1799
+ const boot = bootReporting();
1800
+ if (boot.active) {
1801
+ idleSinceBySubscription.delete(row.id);
1802
+ scheduleAllIdleCheck(Math.max(1_000, boot.remainingMs));
1803
+ continue;
1804
+ }
1805
+ const since = idleSinceBySubscription.get(row.id);
1806
+ if (!since) {
1807
+ idleSinceBySubscription.set(row.id, at);
1808
+ scheduleAllIdleCheck(ALL_IDLE_SETTLE_MS);
1809
+ continue;
1810
+ }
1811
+ if (at - since < ALL_IDLE_SETTLE_MS) {
1812
+ scheduleAllIdleCheck(Math.max(1_000, since + ALL_IDLE_SETTLE_MS - at));
1813
+ continue;
1814
+ }
1815
+ busyBySubscription.set(row.id, false);
1816
+ idleSinceBySubscription.delete(row.id);
1817
+ if (!wants(row, 'all_idle')) continue;
1818
+ const count = toText(row.scope) === 'all' ? null : parseJson(row.session_ids_json, []).length;
1819
+ const event = buildEvent('all_idle', '', { scope: toText(row.scope), sessionCount: count, subscriptionId: row.id });
1820
+ enqueueForRows([row], event, '');
1821
+ }
1822
+ }
1823
+
1824
+ function waitsOnQuestion(sessionId) {
1825
+ try {
1826
+ return !!sql.hasPendingQuestion?.get(sessionId);
1827
+ } catch {
1828
+ return false;
1829
+ }
1830
+ }
1831
+
1832
+ function evaluateStalled() {
1833
+ const at = now();
1834
+ for (const row of localWatchRows()) {
1835
+ if (!wants(row, 'stalled')) continue;
1836
+ const limitMs = (Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault) * 60_000;
1837
+ for (const sessionId of candidateSessions(row)) {
1838
+ const view = describe(sessionId);
1839
+ const key = `${row.id}:${sessionId}`;
1840
+ if (view.state === 'idle' || waitsOnQuestion(sessionId)) {
1841
+ stallReported.delete(key);
1842
+ continue;
1843
+ }
1844
+ const last = Date.parse(String(view.lastActivityAt || '')) || at;
1845
+ if (at - last < limitMs) {
1846
+ stallReported.delete(key);
1847
+ continue;
1848
+ }
1849
+ if (stallReported.get(key) === last) continue;
1850
+ stallReported.set(key, last);
1851
+ const what = view.state === 'running_turn'
1852
+ ? 'a turn'
1853
+ : `${view.runningTaskCount} background task${view.runningTaskCount === 1 ? '' : 's'}`;
1854
+ const event = buildEvent('stalled', sessionId, { quietMinutes: Math.floor((at - last) / 60_000), what, since: view.lastActivityAt });
1855
+ enqueueForRows([row], event, sessionId);
1856
+ }
1857
+ }
1858
+ }
1859
+
1860
+ /**
1861
+ * Delivered events past RELAY_EVENT_DELIVERED_KEEP_MS and subscriptions
1862
+ * ended more than RELAY_EVENT_ENDED_SUBSCRIPTION_KEEP_MS ago go; without
1863
+ * this both tables only ever grew. Returns `{ events, subscriptions }`.
1864
+ */
1865
+ function pruneDelivered() {
1866
+ if (!available) return { events: 0, subscriptions: 0 };
1867
+ const at = now();
1868
+ const events = sql.deleteDeliveredBefore.run(new Date(at - RELAY_EVENT_DELIVERED_KEEP_MS).toISOString()).changes;
1869
+ const subscriptions = deleteEndedSubscriptionsBefore.run(new Date(at - RELAY_EVENT_ENDED_SUBSCRIPTION_KEEP_MS).toISOString()).changes;
1870
+ if (events || subscriptions) {
1871
+ log(`pruned ${events} delivered event${events === 1 ? '' : 's'} and ${subscriptions} ended subscription${subscriptions === 1 ? '' : 's'}`);
1872
+ }
1873
+ return { events, subscriptions };
1874
+ }
1875
+
1876
+ function sweep() {
1877
+ if (!available) return;
1878
+ try { evaluateStalled(); } catch (error) { warn(`stalled check failed: ${toText(error?.message || error).slice(0, 200)}`); }
1879
+ try { evaluateHeartbeats(); } catch (error) { warn(`heartbeat check failed: ${toText(error?.message || error).slice(0, 200)}`); }
1880
+ try { evaluateAllIdle(); } catch (error) { warn(`all_idle check failed: ${toText(error?.message || error).slice(0, 200)}`); }
1881
+ try { pruneServed(); } catch (error) { warn(`retention for paired relays failed: ${toText(error?.message || error).slice(0, 200)}`); }
1882
+ if (now() - lastPruneAt >= PRUNE_INTERVAL_MS) {
1883
+ lastPruneAt = now();
1884
+ try { pruneDelivered(); } catch (error) { warn(`pruning delivered events failed: ${toText(error?.message || error).slice(0, 200)}`); }
1885
+ }
1886
+ }
1887
+
1888
+ /** Boot: the flush timers of undelivered events, the busy marks, the sweep. */
1889
+ function start() {
1890
+ if (!available || started) return;
1891
+ started = true;
1892
+ for (const row of localWatchRows()) noteBusy(row);
1893
+ try {
1894
+ for (const row of sql.listWatchersWithUndelivered.all()) scheduleFlush(toText(row.watcher_conversation_id));
1895
+ } catch {}
1896
+ const tick = () => {
1897
+ sweepTimer = setTimer(() => {
1898
+ sweep();
1899
+ if (started) tick();
1900
+ }, sweepIntervalMs);
1901
+ };
1902
+ tick();
1903
+ }
1904
+
1905
+ function stop() {
1906
+ started = false;
1907
+ if (sweepTimer) clearTimer(sweepTimer);
1908
+ sweepTimer = null;
1909
+ if (allIdleTimer) clearTimer(allIdleTimer);
1910
+ allIdleTimer = null;
1911
+ for (const { timer } of flushTimers.values()) clearTimer(timer);
1912
+ flushTimers.clear();
1913
+ }
1914
+
1915
+ return {
1916
+ isAvailable: () => available,
1917
+ isCrossRelayAvailable: () => crossAvailable,
1918
+ subscribe,
1919
+ subscribeServed,
1920
+ pullServed,
1921
+ pruneServed,
1922
+ pruneDelivered,
1923
+ addMirror,
1924
+ mirrorsOf,
1925
+ remoteRelaysToPoll,
1926
+ remoteWatchers,
1927
+ buildRemotePull,
1928
+ applyRemotePull,
1929
+ noteRemoteLinkRestored,
1930
+ getRemoteLink,
1931
+ noteRemoteLinkOk,
1932
+ noteRemoteLinkFailure,
1933
+ noteRelayRemoved,
1934
+ checkWatcherCapacity,
1935
+ unsubscribe,
1936
+ previewTransfer,
1937
+ transferSubscriptions,
1938
+ listSubscriptions,
1939
+ listActive,
1940
+ record,
1941
+ flush,
1942
+ noteTurnEnd,
1943
+ noteError,
1944
+ noteQuestion,
1945
+ noteTaskEdge,
1946
+ noteConversationGone,
1947
+ afterStateChange,
1948
+ sweep,
1949
+ start,
1950
+ stop,
1951
+ };
1952
+ }