@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -324,6 +324,22 @@ export function createPushDispatchService({
324
324
  }
325
325
  }
326
326
 
327
+ // A turn that answers relay events (its message is the relay's event row,
328
+ // kind 'relay-event') pushes no "reply ready" or "failed" either: the
329
+ // watcher answers events many times an hour and pushes by itself when one
330
+ // matters. Question pushes are unchanged.
331
+ let messageKindStatement = null;
332
+ function turnAnswersRelayEvents(sourceMessageId) {
333
+ const id = String(sourceMessageId || '').trim();
334
+ if (!id) return false;
335
+ try {
336
+ messageKindStatement ??= db.prepare(`SELECT kind FROM messages WHERE id = ?`);
337
+ return String(messageKindStatement.get(id)?.kind || '').trim() === 'relay-event';
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+
327
343
  async function sendToSubscription(row, event) {
328
344
  const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
329
345
  const payload = renderPushNotification(event, preferences);
@@ -440,6 +456,7 @@ export function createPushDispatchService({
440
456
  // the turn.
441
457
  function notifyTurnComplete({ conversationId, messageId, sourceMessageId, text } = {}) {
442
458
  if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
459
+ if (turnAnswersRelayEvents(sourceMessageId)) return Promise.resolve({ outcome: 'relay-event' });
443
460
  return dispatch({
444
461
  type: 'turnComplete',
445
462
  conversationId: conversationId || null,
@@ -450,6 +467,7 @@ export function createPushDispatchService({
450
467
 
451
468
  function notifyTurnFailed({ conversationId, messageId, sourceMessageId, text } = {}) {
452
469
  if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
470
+ if (turnAnswersRelayEvents(sourceMessageId)) return Promise.resolve({ outcome: 'relay-event' });
453
471
  return dispatch({
454
472
  type: 'turnFailed',
455
473
  conversationId: conversationId || null,
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The watching side of relay event subscriptions on paired relays: while
5
+ * this relay holds subscriptions on a paired relay (or an end it has not
6
+ * delivered there yet), it fetches that relay's events every few seconds
7
+ * over the pairing and hands them to the event service, which delivers them
8
+ * like the events of this relay's own sessions. Only this relay calls, so a
9
+ * pairing in one direction is enough.
10
+ *
11
+ * A fetch (POST /api/relay-events/remote/pull) acknowledges what the last
12
+ * one stored, carries the ends and watcher names the other relay must
13
+ * learn, and comes back with new events and the subscriptions' state; the
14
+ * event service writes all of it and the cursor in one transaction, so
15
+ * nothing is lost or delivered twice when either relay restarts.
16
+ *
17
+ * A link with no answer for remoteGapMs (failing fetches, or this relay
18
+ * down) is lost from its last answer on. The first fetch after that, every
19
+ * page of it, is held and delivered as one replayed row per watcher with
20
+ * link_lost and link_restored around it. A shorter outage only delays.
21
+ */
22
+
23
+ import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
24
+ import { RELAY_EVENT_LIMITS } from '../../shared/relay-events.mjs';
25
+
26
+ export const RELAY_EVENT_REMOTE_SUBSCRIBE_PATH = '/api/relay-events/remote/subscribe';
27
+ export const RELAY_EVENT_REMOTE_PULL_PATH = '/api/relay-events/remote/pull';
28
+
29
+ const LOG_PREFIX = '[relay-events]';
30
+ // Pages of one fetch before the next tick goes on (remotePullBatch each).
31
+ const MAX_PAGES_PER_FETCH = 50;
32
+
33
+ function toText(value) {
34
+ return String(value ?? '').trim();
35
+ }
36
+
37
+ /**
38
+ * A paired relay that predates cross-relay subscriptions answers their
39
+ * routes with Express's HTML 404: no JSON, no code. A 404 of these routes
40
+ * always carries one.
41
+ */
42
+ export function isMissingRelayEventRoute(error) {
43
+ return toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound && !toText(error?.remoteBody?.code);
44
+ }
45
+
46
+ export function relayWithoutCrossRelayEventsText(name) {
47
+ return `Relay "${toText(name) || 'the paired relay'}" runs an OAR version without event subscriptions across relays; it needs an update before its sessions can be watched from here.`;
48
+ }
49
+
50
+ function describeFailure(relay, error) {
51
+ if (isMissingRelayEventRoute(error)) return relayWithoutCrossRelayEventsText(relay?.name);
52
+ return toText(error?.message || error).slice(0, 300) || 'no answer';
53
+ }
54
+
55
+ function isPullAnswer(response) {
56
+ return !!response && typeof response === 'object' && Array.isArray(response.events) && Array.isArray(response.subscriptions);
57
+ }
58
+
59
+ export function createRelayEventLinkService({
60
+ relayEvents,
61
+ // `get(id)`: the registry entry (address and token) of a paired relay.
62
+ registry,
63
+ // `request(relay, method, path, { body, timeoutMs })`: remote-relay-client.mjs.
64
+ client,
65
+ now = () => Date.now(),
66
+ setTimer = (fn, ms) => {
67
+ const timer = setTimeout(fn, ms);
68
+ timer.unref?.();
69
+ return timer;
70
+ },
71
+ clearTimer = (timer) => clearTimeout(timer),
72
+ logger = console,
73
+ pollMs = RELAY_EVENT_LIMITS.remotePollMs,
74
+ failingPollMs = RELAY_EVENT_LIMITS.remotePollFailingMs,
75
+ gapMs = RELAY_EVENT_LIMITS.remoteGapMs,
76
+ timeoutMs = 15_000,
77
+ } = {}) {
78
+ if (!relayEvents) throw new Error('relayEvents is required');
79
+ // paired relay id → { timer, running, again, replay }
80
+ const links = new Map();
81
+ let started = false;
82
+
83
+ function warn(line) {
84
+ try { logger?.warn?.(`${LOG_PREFIX} ${line}`); } catch {}
85
+ }
86
+
87
+ function linkState(relayId) {
88
+ if (!links.has(relayId)) links.set(relayId, { timer: null, running: null, again: false, replay: null, replayApplied: false });
89
+ return links.get(relayId);
90
+ }
91
+
92
+ function forget(relayId) {
93
+ const link = links.get(relayId);
94
+ if (link?.timer) clearTimer(link.timer);
95
+ links.delete(relayId);
96
+ }
97
+
98
+ function schedule(relayId, delayMs) {
99
+ if (!started) return;
100
+ const link = linkState(relayId);
101
+ if (link.timer) clearTimer(link.timer);
102
+ link.timer = setTimer(() => {
103
+ link.timer = null;
104
+ void fetchNow(relayId);
105
+ }, Math.max(0, delayMs));
106
+ }
107
+
108
+ function wanted(relayId) {
109
+ return relayEvents.remoteRelaysToPoll().includes(relayId);
110
+ }
111
+
112
+ function failingForLong(relayId) {
113
+ const first = Date.parse(String(relayEvents.getRemoteLink(relayId).first_fail_at || ''));
114
+ return Number.isFinite(first) && now() - first >= gapMs;
115
+ }
116
+
117
+ /** Whether the link has been without an answer long enough to replay: since when. */
118
+ function gapSince(relayId) {
119
+ const health = relayEvents.getRemoteLink(relayId);
120
+ if (health.lost_at) return health.lost_at;
121
+ const lastOk = Date.parse(String(health.last_ok_at || ''));
122
+ return Number.isFinite(lastOk) && now() - lastOk >= gapMs ? health.last_ok_at : null;
123
+ }
124
+
125
+ function startReplay(link, relayId, since) {
126
+ const perWatcher = new Map();
127
+ for (const [watcher, subscriptionId] of relayEvents.remoteWatchers(relayId)) {
128
+ perWatcher.set(watcher, { subscriptionId, kept: 0, dropped: 0, ended: [] });
129
+ }
130
+ link.replay = { since, perWatcher };
131
+ link.replayApplied = false;
132
+ }
133
+
134
+ function addToReplay(link, relayId, applied) {
135
+ for (const [watcher, info] of applied) {
136
+ let entry = link.replay.perWatcher.get(watcher);
137
+ if (!entry) {
138
+ const subscriptionId = relayEvents.remoteWatchers(relayId).get(watcher);
139
+ if (!subscriptionId) continue;
140
+ entry = { subscriptionId, kept: 0, dropped: 0, ended: [] };
141
+ link.replay.perWatcher.set(watcher, entry);
142
+ }
143
+ entry.kept += Number(info.kept) || 0;
144
+ entry.dropped += Number(info.dropped) || 0;
145
+ entry.ended.push(...(info.ended || []));
146
+ }
147
+ }
148
+
149
+ async function fetchPages(relayId, link) {
150
+ const relay = registry?.get?.(relayId) || null;
151
+ if (!relay) {
152
+ // The pairing went without the hook (or before this boot).
153
+ relayEvents.noteRelayRemoved(relayId);
154
+ return { ok: false, removed: true, error: 'The pairing with that relay was removed.' };
155
+ }
156
+ for (let page = 0; page < MAX_PAGES_PER_FETCH; page += 1) {
157
+ const body = relayEvents.buildRemotePull(relayId);
158
+ if (!body.subscriptions.length && !body.ended.length && !link.replay) return { ok: true, idle: true };
159
+ if (!link.replay) {
160
+ const since = gapSince(relayId);
161
+ if (since) startReplay(link, relayId, since);
162
+ }
163
+ let response;
164
+ try {
165
+ response = await client.request(relay, 'POST', RELAY_EVENT_REMOTE_PULL_PATH, { body, timeoutMs });
166
+ if (!isPullAnswer(response)) throw new Error(`Relay "${toText(relay.name)}" gave an unexpected answer to the event fetch`);
167
+ } catch (error) {
168
+ const text = describeFailure(relay, error);
169
+ relayEvents.noteRemoteLinkFailure(relayId, text);
170
+ // Nothing of a replay was written yet: the next fetch starts it again.
171
+ if (link.replay && !link.replayApplied) link.replay = null;
172
+ return { ok: false, error: text, unsupported: isMissingRelayEventRoute(error) };
173
+ }
174
+ const applied = relayEvents.applyRemotePull(relayId, { relayName: relay.name, response, replay: !!link.replay });
175
+ if (link.replay) {
176
+ link.replayApplied = true;
177
+ addToReplay(link, relayId, applied);
178
+ }
179
+ if (response.more !== true) break;
180
+ }
181
+ if (link.replay) {
182
+ const { since, perWatcher } = link.replay;
183
+ link.replay = null;
184
+ link.replayApplied = false;
185
+ relayEvents.noteRemoteLinkRestored(relayId, { relayName: relay.name, since, perWatcher });
186
+ }
187
+ return { ok: true };
188
+ }
189
+
190
+ /**
191
+ * Fetches from a paired relay now (a fetch already running is followed by
192
+ * one more). Resolves `{ ok, error?, idle?, unsupported? }`; never throws.
193
+ */
194
+ function fetchNow(relayId) {
195
+ const id = toText(relayId);
196
+ const link = linkState(id);
197
+ if (link.timer) {
198
+ clearTimer(link.timer);
199
+ link.timer = null;
200
+ }
201
+ if (link.running) {
202
+ link.again = true;
203
+ return link.running;
204
+ }
205
+ link.running = (async () => {
206
+ let result;
207
+ try {
208
+ result = await fetchPages(id, link);
209
+ } catch (error) {
210
+ warn(`fetch from paired relay ${id.slice(0, 12)} failed: ${toText(error?.message || error).slice(0, 200)}`);
211
+ result = { ok: false, error: toText(error?.message || error) };
212
+ } finally {
213
+ link.running = null;
214
+ }
215
+ if (result.removed) {
216
+ forget(id);
217
+ } else if (link.again) {
218
+ link.again = false;
219
+ schedule(id, 0);
220
+ } else if (wanted(id)) {
221
+ schedule(id, result.ok ? pollMs : (failingForLong(id) ? failingPollMs : pollMs));
222
+ } else if (!link.replay) {
223
+ forget(id);
224
+ }
225
+ return result;
226
+ })();
227
+ return link.running;
228
+ }
229
+
230
+ /** A fetch, waited for at most `waitMs` (list_subscriptions wants fresh state). */
231
+ async function refresh(relayId, { waitMs = 5_000 } = {}) {
232
+ let timer = null;
233
+ const timeout = new Promise((resolve) => {
234
+ timer = setTimer(() => resolve({ ok: false, error: 'timeout' }), waitMs);
235
+ });
236
+ try {
237
+ return await Promise.race([fetchNow(relayId), timeout]);
238
+ } finally {
239
+ clearTimer(timer);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The pairing with a relay is being removed (`entry` still has its address
245
+ * and token): one last fetch tells it every subscription ended, then the
246
+ * mirrors end here. A relay that does not answer ends them itself after
247
+ * remoteKeepDays without a fetch.
248
+ */
249
+ async function noteRelayRemoved(entry) {
250
+ const relayId = toText(entry?.id);
251
+ if (!relayId) return;
252
+ forget(relayId);
253
+ const rows = relayEvents.mirrorsOf(relayId);
254
+ if (rows.length && entry?.url) {
255
+ const body = {
256
+ after: Number(relayEvents.getRemoteLink(relayId).cursor) || 0,
257
+ subscriptions: [],
258
+ ended: rows.map((row) => row.remote_subscription_id),
259
+ };
260
+ try {
261
+ await client.request(entry, 'POST', RELAY_EVENT_REMOTE_PULL_PATH, { body, timeoutMs: Math.min(timeoutMs, 10_000) });
262
+ } catch (error) {
263
+ warn(`the paired relay being removed did not take the ends: ${describeFailure(entry, error)}`);
264
+ }
265
+ }
266
+ relayEvents.noteRelayRemoved(relayId);
267
+ }
268
+
269
+ /** Boot: fetch from every paired relay this relay holds subscriptions on. */
270
+ function start() {
271
+ if (started) return;
272
+ started = true;
273
+ let delay = 0;
274
+ for (const relayId of relayEvents.remoteRelaysToPoll()) {
275
+ schedule(relayId, delay);
276
+ delay += 250;
277
+ }
278
+ }
279
+
280
+ function stop() {
281
+ started = false;
282
+ for (const link of links.values()) {
283
+ if (link.timer) clearTimer(link.timer);
284
+ link.timer = null;
285
+ }
286
+ }
287
+
288
+ return { start, stop, fetchNow, refresh, noteRelayRemoved, isPolling: (relayId) => links.has(toText(relayId)) };
289
+ }