@oar-sh/oar 0.9.14 → 0.9.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +525 -12
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
  6. package/server/claude-worker/claude-session-process.mjs +4 -4
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
  8. package/server/db-schema.mjs +52 -0
  9. package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
  10. package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
  11. package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
  12. package/server/migrations/0022-relay-schedules.mjs +142 -0
  13. package/server/migrations/0023-relay-asks.mjs +141 -0
  14. package/server/public/app/ask-user-view.js +3 -0
  15. package/server/public/app/bootstrap.js +61 -3
  16. package/server/public/app/context-usage-view.mjs +17 -0
  17. package/server/public/app/conversation-group-order.mjs +35 -0
  18. package/server/public/app/inbox-ui.js +356 -0
  19. package/server/public/app/inbox-view.mjs +370 -0
  20. package/server/public/app/journal-view.js +23 -2
  21. package/server/public/app/relay-event-subscriptions-ui.js +4 -2
  22. package/server/public/app/relay-event-view.mjs +33 -7
  23. package/server/public/app/relay-schedule-view.mjs +119 -0
  24. package/server/public/app/relay-schedules-ui.js +172 -0
  25. package/server/public/app/settings-modal.js +17 -3
  26. package/server/public/app/socket-handlers.js +27 -3
  27. package/server/public/app/store.js +18 -5
  28. package/server/public/index.html +159 -2
  29. package/server/relay-tools.md +18 -3
  30. package/server/repositories/conversation-group-repository.mjs +97 -11
  31. package/server/repositories/remote-relay-repository.mjs +20 -2
  32. package/server/repositories/session-repository.mjs +7 -1
  33. package/server/routes/ask-user-routes.mjs +57 -3
  34. package/server/routes/cache-routes.mjs +6 -0
  35. package/server/routes/conversation-group-routes.mjs +17 -2
  36. package/server/routes/messages-routes.mjs +3 -3
  37. package/server/routes/relay-ask-routes.mjs +387 -0
  38. package/server/routes/relay-event-routes.mjs +3 -1
  39. package/server/routes/relay-schedule-routes.mjs +57 -0
  40. package/server/routes/sessions-routes.mjs +613 -56
  41. package/server/server-runtime.mjs +80 -1
  42. package/server/services/cache-rebuild-service.mjs +19 -2
  43. package/server/services/conversation-preferences-service.mjs +17 -0
  44. package/server/services/oar-cli-helpers.mjs +12 -4
  45. package/server/services/relay-ask-link-service.mjs +174 -0
  46. package/server/services/relay-ask-service.mjs +604 -0
  47. package/server/services/relay-event-service.mjs +589 -73
  48. package/server/services/relay-schedule-service.mjs +596 -0
  49. package/server/services/remote-relay-caller-context.mjs +89 -20
  50. package/server/services/remote-relay-dispatcher.mjs +956 -30
  51. package/server/services/windows-autostart-service.mjs +88 -16
  52. package/server/services/windows-boot-autostart-service.mjs +775 -126
  53. package/shared/relay-asks.mjs +145 -0
  54. package/shared/relay-events.mjs +268 -18
  55. package/shared/relay-schedules.mjs +128 -0
  56. package/shared/remote-relay-contract.mjs +553 -25
  57. package/shared/remote-relay-tool-core.mjs +16 -5
  58. package/.github/extensions/web-relay/README.md +0 -190
  59. package/server/README.md +0 -2334
@@ -0,0 +1,596 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Relay schedules: a prompt the relay queues into a session at a set time,
5
+ * once or every N minutes (remote_relay's `schedule`). An agent's own timers
6
+ * die with the relay; these live in the database (migrations/0022-relay-schedules.mjs)
7
+ * and their timers are rebuilt at boot.
8
+ *
9
+ * A firing is an ordinary queued message of the relay's (`messages.kind =
10
+ * 'relay-schedule'`, a pending queue row whose `next_attempt_at` is the time
11
+ * it was due), like the usage-limit follow-up: the worker takes it as it
12
+ * takes any message, a new turn when the session is idle, the next one when
13
+ * it is busy. The row, the schedule's next time and its end are written in
14
+ * one transaction, so a restart neither loses a firing nor repeats it.
15
+ *
16
+ * - A time that passed while the relay was down fires once at boot, not
17
+ * once per missed interval; a repeating schedule then keeps to its grid
18
+ * (the first time plus whole intervals).
19
+ * - A repeat is skipped while the message of the firing before it is still
20
+ * waiting in the queue: a stuck session gets one, not a pile.
21
+ * - A schedule ends after firing when `once`, on unschedule, and when the
22
+ * session it fires into or the conversation that set it is archived or
23
+ * deleted (checked again when it comes due). A compaction carries it to
24
+ * the successor instead, in both directions (carryToCompacted).
25
+ * - A conversation holds at most RELAY_SCHEDULE_LIMITS.perConversation
26
+ * active schedules firing into it, and may set as many.
27
+ * - A firing into another session is a prompt of the conversation that set
28
+ * it, counted by the agents' loop guard (`admitFiring`). When that guard
29
+ * wants the user first, the firing is held (`held_since`) and looked at
30
+ * again every minute; it goes once the user wrote in that conversation.
31
+ * - Its messages carry the provenance of the turn that set it
32
+ * (`origin_json`, with that turn's hops): a schedule passes no prompt on
33
+ * with fewer hops than it came with.
34
+ */
35
+
36
+ import { randomBytes, randomUUID } from 'node:crypto';
37
+
38
+ import {
39
+ RELAY_SCHEDULE_LIMITS,
40
+ RELAY_SCHEDULE_MESSAGE_KIND,
41
+ RELAY_SCHEDULE_SOCKET_EVENT,
42
+ buildRelayScheduleMessageText,
43
+ firstRelayScheduleFire,
44
+ nextRelayScheduleFire,
45
+ normalizeRelayScheduleTitle,
46
+ } from '../../shared/relay-schedules.mjs';
47
+ import { relaySchedulesTableProblem } from '../migrations/0022-relay-schedules.mjs';
48
+
49
+ const LOG_PREFIX = '[relay-schedules]';
50
+ // setTimeout holds at most ~24.8 days; a schedule further out is looked at
51
+ // again after this long.
52
+ const MAX_TIMER_MS = 6 * 60 * 60_000;
53
+ // Ended schedules are kept this long (list_schedules shows the last hour).
54
+ const ENDED_KEEP_MS = 7 * 24 * 60 * 60_000;
55
+ const ENDED_LISTED_MS = 60 * 60_000;
56
+ const TEXT_PREVIEW_CHARS = 200;
57
+ // A firing held for the user's approval is looked at again this often.
58
+ export const RELAY_SCHEDULE_HOLD_RECHECK_MS = 60_000;
59
+
60
+ function parseJson(value) {
61
+ try {
62
+ const parsed = JSON.parse(String(value ?? ''));
63
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function toText(value) {
70
+ return String(value ?? '').trim();
71
+ }
72
+
73
+ function shortId(value) {
74
+ return toText(value).slice(0, 8);
75
+ }
76
+
77
+ function msOf(value) {
78
+ const ms = Date.parse(toText(value));
79
+ return Number.isFinite(ms) ? ms : null;
80
+ }
81
+
82
+ export class RelayScheduleError extends Error {
83
+ constructor(code, message, extra = {}) {
84
+ super(message);
85
+ this.code = code;
86
+ this.extra = extra;
87
+ }
88
+ }
89
+
90
+ export function createRelayScheduleService({
91
+ db,
92
+ stmts,
93
+ emit = () => {},
94
+ // A message was queued: the primer starts a stopped worker, the socket
95
+ // service hands it to a running one.
96
+ wake = () => {},
97
+ now = () => Date.now(),
98
+ setTimer = (fn, ms) => {
99
+ const timer = setTimeout(fn, ms);
100
+ timer.unref?.();
101
+ return timer;
102
+ },
103
+ clearTimer = (timer) => clearTimeout(timer),
104
+ newId = () => `sch_${randomBytes(6).toString('hex')}`,
105
+ // `({ scheduleId, conversationId, createdBy })` → true when a firing into
106
+ // another session may go: the agents' loop guard of the conversation that
107
+ // set it counts it (remote-relay-dispatcher.mjs admitScheduledFiring).
108
+ admitFiring = () => true,
109
+ logger = console,
110
+ } = {}) {
111
+ if (!db) throw new Error('db is required');
112
+ if (!stmts) throw new Error('stmts is required');
113
+
114
+ const problem = (() => {
115
+ try {
116
+ return relaySchedulesTableProblem(db);
117
+ } catch (error) {
118
+ return toText(error?.message || error) || 'unreadable';
119
+ }
120
+ })();
121
+ const available = !problem;
122
+
123
+ const sql = available ? {
124
+ insert: db.prepare(`
125
+ INSERT INTO relay_schedules
126
+ (id, conversation_id, created_by_conversation_id, title, text, at, every_minutes, once, next_fire_at, created_at, origin_json)
127
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
128
+ `),
129
+ get: db.prepare(`SELECT * FROM relay_schedules WHERE id = ?`),
130
+ listActive: db.prepare(`SELECT * FROM relay_schedules WHERE ended_at IS NULL ORDER BY created_at ASC, rowid ASC`),
131
+ listOf: db.prepare(`
132
+ SELECT * FROM relay_schedules
133
+ WHERE (conversation_id = ? OR created_by_conversation_id = ?) AND (ended_at IS NULL OR ended_at >= ?)
134
+ ORDER BY created_at ASC, rowid ASC
135
+ `),
136
+ listActiveOf: db.prepare(`
137
+ SELECT * FROM relay_schedules
138
+ WHERE (conversation_id = ? OR created_by_conversation_id = ?) AND ended_at IS NULL
139
+ `),
140
+ countFiringInto: db.prepare(`SELECT COUNT(*) AS n FROM relay_schedules WHERE conversation_id = ? AND ended_at IS NULL`),
141
+ countSetBy: db.prepare(`SELECT COUNT(*) AS n FROM relay_schedules WHERE created_by_conversation_id = ? AND ended_at IS NULL`),
142
+ end: db.prepare(`UPDATE relay_schedules SET ended_at = ?, end_reason = ?, next_fire_at = NULL WHERE id = ? AND ended_at IS NULL`),
143
+ markFired: db.prepare(`
144
+ UPDATE relay_schedules
145
+ SET last_fired_at = ?, fired_count = fired_count + 1, last_message_id = ?, next_fire_at = ?,
146
+ ended_at = ?, end_reason = ?, held_since = NULL
147
+ WHERE id = ? AND ended_at IS NULL
148
+ `),
149
+ markHeld: db.prepare(`UPDATE relay_schedules SET held_since = COALESCE(held_since, ?) WHERE id = ? AND ended_at IS NULL`),
150
+ // A compaction: the successor takes over both sides.
151
+ carryTarget: db.prepare(`UPDATE relay_schedules SET conversation_id = ? WHERE conversation_id = ? AND ended_at IS NULL`),
152
+ carryCreator: db.prepare(`UPDATE relay_schedules SET created_by_conversation_id = ? WHERE created_by_conversation_id = ? AND ended_at IS NULL`),
153
+ markSkipped: db.prepare(`
154
+ UPDATE relay_schedules SET skipped_count = skipped_count + 1, next_fire_at = ? WHERE id = ? AND ended_at IS NULL
155
+ `),
156
+ pruneEnded: db.prepare(`DELETE FROM relay_schedules WHERE ended_at IS NOT NULL AND ended_at < ?`),
157
+ getConversation: db.prepare(`
158
+ SELECT id, title, archived, status, preferred_relay_mode, preferred_reasoning_effort
159
+ FROM conversations WHERE id = ?
160
+ `),
161
+ queueStatus: db.prepare(`SELECT status FROM queue WHERE id = ?`),
162
+ setNextAttempt: db.prepare(`UPDATE queue SET next_attempt_at = ? WHERE id = ? AND status = 'pending'`),
163
+ // Migration 0005's provenance column; without it a message carries none.
164
+ setMessageOrigin: db.prepare(`PRAGMA table_info(messages)`).all().some((column) => column.name === 'origin_json')
165
+ ? db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`)
166
+ : null,
167
+ } : null;
168
+
169
+ // schedule id → timer
170
+ const timers = new Map();
171
+ let started = false;
172
+
173
+ function isoNow() {
174
+ return new Date(now()).toISOString();
175
+ }
176
+
177
+ function log(line) {
178
+ try { logger?.log?.(`${LOG_PREFIX} ${line}`); } catch {}
179
+ }
180
+
181
+ function warn(line) {
182
+ try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
183
+ }
184
+
185
+ function requireAvailable() {
186
+ if (!available) {
187
+ throw new RelayScheduleError('unavailable', 'Schedules are not available on this relay right now (see its server log).');
188
+ }
189
+ }
190
+
191
+ function conversation(id) {
192
+ const key = toText(id);
193
+ if (!key) return null;
194
+ try {
195
+ return sql.getConversation.get(key) || null;
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ function isLive(row) {
202
+ return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
203
+ }
204
+
205
+ function titleOf(id) {
206
+ return toText(conversation(id)?.title) || null;
207
+ }
208
+
209
+ /** A schedule as agents and the browser see it. */
210
+ function shape(row) {
211
+ const own = row.conversation_id === row.created_by_conversation_id;
212
+ const text = String(row.text || '');
213
+ return {
214
+ id: row.id,
215
+ session: { id: row.conversation_id, title: titleOf(row.conversation_id) },
216
+ ...(own ? {} : { setBy: { id: row.created_by_conversation_id, title: titleOf(row.created_by_conversation_id) } }),
217
+ title: toText(row.title) || null,
218
+ text: text.length > TEXT_PREVIEW_CHARS ? `${text.slice(0, TEXT_PREVIEW_CHARS - 1)}…` : text,
219
+ at: row.at || null,
220
+ everyMinutes: row.every_minutes ? Number(row.every_minutes) : null,
221
+ once: Number(row.once) === 1,
222
+ nextFireAt: row.next_fire_at || null,
223
+ lastFiredAt: row.last_fired_at || null,
224
+ firedCount: Number(row.fired_count || 0),
225
+ ...(Number(row.skipped_count || 0) ? { skippedCount: Number(row.skipped_count) } : {}),
226
+ // Due, but held until the user writes in the conversation that set it.
227
+ ...(row.held_since && !row.ended_at ? { heldForApproval: true, heldSince: row.held_since } : {}),
228
+ createdAt: row.created_at,
229
+ ...(row.ended_at ? { endedAt: row.ended_at, endReason: row.end_reason || null } : {}),
230
+ };
231
+ }
232
+
233
+ function listActive() {
234
+ if (!available) return [];
235
+ return sql.listActive.all().map(shape);
236
+ }
237
+
238
+ function broadcast() {
239
+ try { emit(RELAY_SCHEDULE_SOCKET_EVENT, { schedules: listActive() }); } catch {}
240
+ }
241
+
242
+ // ─── Timers ────────────────────────────────────────────────────────────────
243
+
244
+ function disarm(id) {
245
+ const timer = timers.get(id);
246
+ if (timer) clearTimer(timer);
247
+ timers.delete(id);
248
+ }
249
+
250
+ function arm(row) {
251
+ disarm(row.id);
252
+ if (!started || row.ended_at) return;
253
+ const dueMs = msOf(row.next_fire_at);
254
+ if (dueMs === null) return;
255
+ // A held firing is due already: it is looked at again after a while.
256
+ const delay = row.held_since
257
+ ? RELAY_SCHEDULE_HOLD_RECHECK_MS
258
+ : Math.max(0, Math.min(dueMs - now(), MAX_TIMER_MS));
259
+ timers.set(row.id, setTimer(() => {
260
+ timers.delete(row.id);
261
+ try {
262
+ fireIfDue(row.id);
263
+ } catch (error) {
264
+ warn(`schedule ${row.id} failed to fire: ${toText(error?.message || error).slice(0, 200)}`);
265
+ }
266
+ }, delay));
267
+ }
268
+
269
+ // ─── Changes ───────────────────────────────────────────────────────────────
270
+
271
+ function checkCapacity(target, creator) {
272
+ const max = RELAY_SCHEDULE_LIMITS.perConversation;
273
+ if (Number(sql.countFiringInto.get(target)?.n || 0) >= max) {
274
+ throw new RelayScheduleError('limit', `Session ${target} already has ${max} schedules; unschedule one first (list_schedules lists them).`, { limit: max });
275
+ }
276
+ if (creator !== target && Number(sql.countSetBy.get(creator)?.n || 0) >= max) {
277
+ throw new RelayScheduleError('limit', `This conversation has set ${max} schedules already; unschedule one first (list_schedules lists them).`, { limit: max });
278
+ }
279
+ }
280
+
281
+ /**
282
+ * A new schedule. `conversationId` is the session the prompt goes to,
283
+ * `createdBy` the conversation whose agent set it, `origin` the provenance
284
+ * its messages carry (a remote-relay origin with the setting turn's hops,
285
+ * or null). Returns the schedule.
286
+ */
287
+ function create({ conversationId, createdBy, text, at = null, everyMinutes = null, once = null, title = '', origin = null } = {}) {
288
+ requireAvailable();
289
+ const target = toText(conversationId);
290
+ const creator = toText(createdBy) || target;
291
+ const body = String(text ?? '').trim();
292
+ if (!body) throw new RelayScheduleError('invalid', 'A schedule needs text.');
293
+ if (body.length > RELAY_SCHEDULE_LIMITS.textMax) {
294
+ throw new RelayScheduleError('invalid', `text is longer than ${RELAY_SCHEDULE_LIMITS.textMax} characters`);
295
+ }
296
+ const every = everyMinutes === null || everyMinutes === undefined ? null : Number(everyMinutes);
297
+ if (every !== null && (!Number.isInteger(every) || every < RELAY_SCHEDULE_LIMITS.everyMinutesMin || every > RELAY_SCHEDULE_LIMITS.everyMinutesMax)) {
298
+ throw new RelayScheduleError('invalid', `every_minutes must be ${RELAY_SCHEDULE_LIMITS.everyMinutesMin} to ${RELAY_SCHEDULE_LIMITS.everyMinutesMax}`);
299
+ }
300
+ const atMs = msOf(at);
301
+ if (atMs === null && every === null) throw new RelayScheduleError('invalid', 'A schedule needs at or every_minutes.');
302
+ const isOnce = once === null || once === undefined ? every === null : once === true;
303
+ if (!isOnce && every === null) throw new RelayScheduleError('invalid', 'A schedule that repeats needs every_minutes.');
304
+ if (!isLive(conversation(target))) {
305
+ throw new RelayScheduleError('notFound', `No live session ${target} on this relay; use an id from list_sessions (archived sessions take no schedules).`, { session: target });
306
+ }
307
+ if (creator !== target && !isLive(conversation(creator))) {
308
+ throw new RelayScheduleError('notFound', 'The conversation setting the schedule was not found or is archived.');
309
+ }
310
+ const at0 = atMs === null ? null : new Date(atMs).toISOString();
311
+ const createdAt = isoNow();
312
+ const next = firstRelayScheduleFire({ at: at0, everyMinutes: every, now: now() });
313
+ let id = '';
314
+ db.transaction(() => {
315
+ checkCapacity(target, creator);
316
+ id = newId();
317
+ const originJson = origin && typeof origin === 'object' ? JSON.stringify(origin) : null;
318
+ sql.insert.run(id, target, creator, normalizeRelayScheduleTitle(title) || null, body, at0, every, isOnce ? 1 : 0, next, createdAt, originJson);
319
+ })();
320
+ const row = sql.get.get(id);
321
+ arm(row);
322
+ log(`${id} set for conv=${shortId(target)}${creator !== target ? ` by conv=${shortId(creator)}` : ''}: next ${next}${every ? `, every ${every} min` : ''}${isOnce ? ', once' : ''}`);
323
+ broadcast();
324
+ return shape(row);
325
+ }
326
+
327
+ /**
328
+ * The schedules that fire into a conversation or that it set: the active
329
+ * ones and the ones that ended within the last hour.
330
+ */
331
+ function list({ conversationId } = {}) {
332
+ requireAvailable();
333
+ const id = toText(conversationId);
334
+ return sql.listOf.all(id, id, new Date(now() - ENDED_LISTED_MS).toISOString()).map(shape);
335
+ }
336
+
337
+ function get(scheduleId) {
338
+ if (!available) return null;
339
+ const row = sql.get.get(toText(scheduleId));
340
+ return row ? shape(row) : null;
341
+ }
342
+
343
+ function endRow(row, reason) {
344
+ const changed = sql.end.run(isoNow(), reason, row.id).changes > 0;
345
+ disarm(row.id);
346
+ return changed;
347
+ }
348
+
349
+ /**
350
+ * End a schedule. `byConversationId` (an agent's call) may end only one
351
+ * that fires into it or that it set; without it (the user) any.
352
+ */
353
+ function unschedule({ scheduleId, byConversationId = null } = {}) {
354
+ requireAvailable();
355
+ const id = toText(scheduleId);
356
+ const row = id ? sql.get.get(id) : null;
357
+ const by = toText(byConversationId);
358
+ if (!row || (by && row.conversation_id !== by && row.created_by_conversation_id !== by)) {
359
+ throw new RelayScheduleError('notFound', `No schedule ${id} of this conversation; list_schedules lists them.`, { schedule_id: id });
360
+ }
361
+ if (row.ended_at) return shape(row);
362
+ endRow(row, 'unscheduled');
363
+ log(`${id} unscheduled${by ? ` by conv=${shortId(by)}` : ' by the user'}`);
364
+ broadcast();
365
+ return shape(sql.get.get(id));
366
+ }
367
+
368
+ /** A conversation was archived or deleted: the schedules that fire into it or that it set end. */
369
+ function noteConversationGone(conversationId, { deleted = false } = {}) {
370
+ if (!available) return 0;
371
+ const id = toText(conversationId);
372
+ if (!id) return 0;
373
+ let ended = 0;
374
+ for (const row of sql.listActiveOf.all(id, id)) {
375
+ if (endRow(row, deleted ? 'deleted' : 'archived')) ended += 1;
376
+ }
377
+ if (ended) {
378
+ log(`conv=${shortId(id)} ${deleted ? 'deleted' : 'archived'}: ${ended} schedule${ended === 1 ? '' : 's'} ended`);
379
+ broadcast();
380
+ }
381
+ return ended;
382
+ }
383
+
384
+ /**
385
+ * A conversation was compacted into a new one: its schedules go on there,
386
+ * the ones that fire into it and the ones it set (a nightly schedule of its
387
+ * own survives the compaction). Returns how many moved.
388
+ */
389
+ function carryToCompacted(fromConversationId, toConversationId) {
390
+ if (!available) return 0;
391
+ const from = toText(fromConversationId);
392
+ const to = toText(toConversationId);
393
+ if (!from || !to || from === to) return 0;
394
+ const moved = db.transaction(() => sql.carryTarget.run(to, from).changes + sql.carryCreator.run(to, from).changes)();
395
+ if (moved) {
396
+ log(`conv=${shortId(from)} compacted into conv=${shortId(to)}: its schedules moved along`);
397
+ broadcast();
398
+ }
399
+ return moved;
400
+ }
401
+
402
+ // ─── Firing ────────────────────────────────────────────────────────────────
403
+
404
+ function queueMessage(target, text, dueAt, origin) {
405
+ const messageId = randomUUID();
406
+ const at = isoNow();
407
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(target.id) || null;
408
+ const latestModel = stmts.getLatestConversationModel?.get?.(target.id)?.model || null;
409
+ // The model the conversation last ran on, as a relay event picks it.
410
+ const model = toText(latestModel) || toText(runtimeSession?.provider_model) || toText(runtimeSession?.model) || null;
411
+ const relayMode = toText(target.preferred_relay_mode) || 'agent';
412
+ const owner = toText(runtimeSession?.sdk_session_id) || null;
413
+ stmts.insertMsg.run(
414
+ messageId,
415
+ target.id,
416
+ 'user',
417
+ text,
418
+ model,
419
+ relayMode,
420
+ null,
421
+ at,
422
+ model,
423
+ null,
424
+ toText(model).toLowerCase() === 'auto' ? 'auto' : 'manual',
425
+ );
426
+ stmts.setMessageKind?.run?.(RELAY_SCHEDULE_MESSAGE_KIND, messageId);
427
+ // The provenance of the turn that set the schedule: its hops go on.
428
+ if (origin) sql.setMessageOrigin?.run(JSON.stringify(origin), messageId);
429
+ stmts.updateConvTime?.run?.(at, target.id);
430
+ const queueArgs = [
431
+ messageId,
432
+ target.id,
433
+ runtimeSession?.id || null,
434
+ 0,
435
+ model,
436
+ model,
437
+ toText(target.preferred_reasoning_effort) || null,
438
+ null,
439
+ relayMode,
440
+ text,
441
+ null,
442
+ at,
443
+ owner,
444
+ owner ? at : null,
445
+ null,
446
+ null,
447
+ ];
448
+ if (stmts.queueHasImageOperationId || stmts.insertQ?.source?.includes?.('image_operation_id')) queueArgs.push(null);
449
+ stmts.insertQ.run(...queueArgs);
450
+ // Due since the schedule's time: the delivery takes it at once.
451
+ sql.setNextAttempt.run(dueAt, messageId);
452
+ return { messageId, model, relayMode, owner, timestamp: at, text, origin };
453
+ }
454
+
455
+ function goneReason(row) {
456
+ return toText(row?.status) === 'deleted' || !row ? 'deleted' : 'archived';
457
+ }
458
+
459
+ /**
460
+ * Fire a schedule whose time has come; anything else is left (re-armed).
461
+ * Returns `{ fired, skipped, held, ended, messageId }` or null.
462
+ */
463
+ function fireIfDue(scheduleId) {
464
+ if (!available) return null;
465
+ const row = sql.get.get(toText(scheduleId));
466
+ if (!row || row.ended_at) return null;
467
+ const dueMs = msOf(row.next_fire_at);
468
+ if (dueMs === null) return null;
469
+ if (dueMs > now()) {
470
+ arm(row);
471
+ return null;
472
+ }
473
+ const target = conversation(row.conversation_id);
474
+ const forOther = row.created_by_conversation_id !== row.conversation_id;
475
+ // The session it fires into, and the one that set it, must still be
476
+ // there (a compaction carried them over to the successor).
477
+ const creator = forOther ? conversation(row.created_by_conversation_id) : target;
478
+ if (!isLive(target) || !isLive(creator)) {
479
+ endRow(row, goneReason(!isLive(target) ? target : creator));
480
+ broadcast();
481
+ return { fired: false, skipped: false, held: false, ended: true, messageId: null };
482
+ }
483
+ const dueAt = new Date(dueMs).toISOString();
484
+ const every = row.every_minutes ? Number(row.every_minutes) : null;
485
+ const once = Number(row.once) === 1;
486
+ const next = once ? null : nextRelayScheduleFire({ dueAt, everyMinutes: every, now: now() });
487
+ const waiting = row.last_message_id ? toText(sql.queueStatus.get(row.last_message_id)?.status) : '';
488
+ // The firing before still waits for the session: this one is skipped
489
+ // (a once schedule has none before it).
490
+ const skip = !once && (waiting === 'pending' || waiting === 'parked');
491
+ // A firing into another session is a prompt of the conversation that set
492
+ // it: the agents' loop guard counts it, and holds it once it wants the
493
+ // user first (it goes after the user wrote in that conversation).
494
+ let admitted = true;
495
+ if (!skip && forOther) {
496
+ try {
497
+ admitted = admitFiring({ scheduleId: row.id, conversationId: row.conversation_id, createdBy: row.created_by_conversation_id }) !== false;
498
+ } catch (error) {
499
+ warn(`schedule ${row.id}: the loop guard could not be asked (${toText(error?.message || error).slice(0, 200)}); held`);
500
+ admitted = false;
501
+ }
502
+ }
503
+ if (!admitted) {
504
+ const first = !row.held_since;
505
+ sql.markHeld.run(isoNow(), row.id);
506
+ arm(sql.get.get(row.id));
507
+ if (first) {
508
+ log(`${row.id} held for conv=${shortId(row.conversation_id)}: conv=${shortId(row.created_by_conversation_id)} has prompted other agents as often as it may before the user writes`);
509
+ broadcast();
510
+ }
511
+ return { fired: false, skipped: false, held: true, ended: false, messageId: null };
512
+ }
513
+ const origin = parseJson(row.origin_json);
514
+ let queued = null;
515
+ db.transaction(() => {
516
+ if (skip) {
517
+ sql.markSkipped.run(next, row.id);
518
+ return;
519
+ }
520
+ const setBy = row.created_by_conversation_id !== row.conversation_id
521
+ ? { id: row.created_by_conversation_id, title: titleOf(row.created_by_conversation_id) }
522
+ : null;
523
+ const text = buildRelayScheduleMessageText({ id: row.id, title: row.title, setBy, text: row.text });
524
+ queued = queueMessage(target, text, dueAt, origin);
525
+ sql.markFired.run(isoNow(), queued.messageId, next, once ? isoNow() : null, once ? 'fired' : null, row.id);
526
+ })();
527
+ const after = sql.get.get(row.id);
528
+ if (after && !after.ended_at) arm(after);
529
+ else disarm(row.id);
530
+ if (skip) {
531
+ log(`${row.id} skipped for conv=${shortId(row.conversation_id)}: the message before still waits; next ${next}`);
532
+ broadcast();
533
+ return { fired: false, skipped: true, held: false, ended: false, messageId: null };
534
+ }
535
+ try {
536
+ emit('user_message', {
537
+ conversationId: target.id,
538
+ messageId: queued.messageId,
539
+ senderClientId: null,
540
+ message: {
541
+ role: 'user',
542
+ text: queued.text,
543
+ model: queued.model,
544
+ mode: queued.relayMode,
545
+ attachments: [],
546
+ timestamp: queued.timestamp,
547
+ kind: RELAY_SCHEDULE_MESSAGE_KIND,
548
+ ...(queued.origin ? { origin: queued.origin } : {}),
549
+ },
550
+ });
551
+ } catch {}
552
+ try { emit('message_status', { messageId: queued.messageId, conversationId: target.id, status: 'pending' }); } catch {}
553
+ try { wake({ conversationId: target.id, ownerSessionId: queued.owner, messageId: queued.messageId }); } catch {}
554
+ log(`${row.id} fired into conv=${shortId(target.id)}${once ? ' (ended: once)' : `; next ${next}`}`);
555
+ broadcast();
556
+ return { fired: true, skipped: false, held: false, ended: once, messageId: queued.messageId };
557
+ }
558
+
559
+ function pruneEnded() {
560
+ if (!available) return 0;
561
+ return sql.pruneEnded.run(new Date(now() - ENDED_KEEP_MS).toISOString()).changes;
562
+ }
563
+
564
+ /** Boot: every active schedule gets its timer; one that was due while the relay was down fires now, once. */
565
+ function start() {
566
+ if (!available) {
567
+ if (problem) warn(`schedules are off: ${problem}`);
568
+ return;
569
+ }
570
+ if (started) return;
571
+ started = true;
572
+ try { pruneEnded(); } catch {}
573
+ for (const row of sql.listActive.all()) arm(row);
574
+ }
575
+
576
+ function stop() {
577
+ started = false;
578
+ for (const timer of timers.values()) clearTimer(timer);
579
+ timers.clear();
580
+ }
581
+
582
+ return {
583
+ isAvailable: () => available,
584
+ create,
585
+ list,
586
+ listActive,
587
+ get,
588
+ unschedule,
589
+ noteConversationGone,
590
+ carryToCompacted,
591
+ fireIfDue,
592
+ pruneEnded,
593
+ start,
594
+ stop,
595
+ };
596
+ }