@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
@@ -26,6 +26,13 @@ import {
26
26
  } from '../../shared/remote-relay-contract.mjs';
27
27
  import { schemaFields } from '../../shared/question-schema.mjs';
28
28
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
29
+ import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
30
+ import {
31
+ RELAY_EVENT_REMOTE_PULL_PATH,
32
+ RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
33
+ isMissingRelayEventRoute,
34
+ relayWithoutCrossRelayEventsText,
35
+ } from './relay-event-link-service.mjs';
29
36
 
30
37
  // The one place a `remote_relay` tool call is decided and carried out (plan
31
38
  // §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
@@ -145,6 +152,12 @@ function isPlainObject(value) {
145
152
  return !!value && typeof value === 'object' && !Array.isArray(value);
146
153
  }
147
154
 
155
+ const SESSION_STATES = new Set(['running_turn', 'background_only', 'idle']);
156
+
157
+ function isBusyState(state) {
158
+ return state === 'running_turn' || state === 'background_only';
159
+ }
160
+
148
161
  function shortId(value) {
149
162
  return toText(value).slice(0, 8) || '-';
150
163
  }
@@ -563,6 +576,12 @@ export function createRemoteRelayDispatcher({
563
576
  // conversations do not share with their conversation id: map it back, so
564
577
  // the unlock gate and the caller context read the right conversation.
565
578
  resolveConversationId = (id) => id,
579
+ // The relay event service (relay-event-service.mjs): subscribe and its
580
+ // siblings. Without it those actions answer that the relay lacks them.
581
+ relayEvents = null,
582
+ // The event link (relay-event-link-service.mjs): fetches the events of
583
+ // subscriptions on paired relays. Without it those stay refused.
584
+ relayEventLink = null,
566
585
  } = {}) {
567
586
  const inflightByConversation = new Map();
568
587
  const callTimesByConversation = new Map();
@@ -578,6 +597,8 @@ export function createRemoteRelayDispatcher({
578
597
  // The once-per-conversation approval card while it is open: parallel first
579
598
  // creates share the one card and its answer.
580
599
  const localApprovalsByConversation = new Map();
600
+ // The same for the first subscribe of a conversation (by approval key).
601
+ const watchApprovalsByKey = new Map();
581
602
 
582
603
  // ─── Bookkeeping ───────────────────────────────────────────────────────────
583
604
 
@@ -1051,6 +1072,123 @@ export function createRemoteRelayDispatcher({
1051
1072
 
1052
1073
  // ─── Actions ───────────────────────────────────────────────────────────────
1053
1074
 
1075
+ // Plan usage as the agent may see it: an allow-list of fields, so a field
1076
+ // the relay adds later never leaks. No account names, links or raw
1077
+ // payloads; an address or a token-shaped string in a text is masked.
1078
+ const USAGE_TEXT_MAX = 300;
1079
+
1080
+ function usageText(value, max = USAGE_TEXT_MAX) {
1081
+ const text = clipLine(value, max);
1082
+ if (!text) return null;
1083
+ return text
1084
+ .replace(/[^\s@<>()"',;:]+@[^\s@<>()"',;:]+\.[A-Za-z]{2,}/g, '[address]')
1085
+ .replace(/\b[A-Za-z0-9_\-]{32,}\b/g, '[hidden]');
1086
+ }
1087
+
1088
+ function usageNumber(value) {
1089
+ const number = Number(value);
1090
+ return value !== null && value !== undefined && value !== '' && Number.isFinite(number) ? number : null;
1091
+ }
1092
+
1093
+ function usageMeter(meter) {
1094
+ if (!isPlainObject(meter)) return null;
1095
+ const label = usageText(meter.label, 80);
1096
+ if (!label) return null;
1097
+ return {
1098
+ label,
1099
+ unit: toText(meter.unit) || null,
1100
+ ...(meter.unlimited === true ? { unlimited: true } : {}),
1101
+ ...(meter.estimated === true ? { estimated: true } : {}),
1102
+ used: usageNumber(meter.used),
1103
+ allowance: usageNumber(meter.allowance),
1104
+ remaining: usageNumber(meter.remaining),
1105
+ utilization: usageNumber(meter.utilization),
1106
+ resetAt: toText(meter.resetAt) || null,
1107
+ ...(toText(meter.resetKind) ? { resetKind: toText(meter.resetKind) } : {}),
1108
+ ...(toText(meter.severity) ? { severity: toText(meter.severity) } : {}),
1109
+ ...(usageText(meter.note) ? { note: usageText(meter.note) } : {}),
1110
+ };
1111
+ }
1112
+
1113
+ function usageCard(card) {
1114
+ if (!isPlainObject(card) || !toText(card.provider)) return null;
1115
+ const meters = (Array.isArray(card.meters) ? card.meters : []).slice(0, 12).map(usageMeter).filter(Boolean);
1116
+ const details = (Array.isArray(card.details) ? card.details : []).slice(0, 8).map((section) => {
1117
+ const rows = (Array.isArray(section?.rows) ? section.rows : []).slice(0, 12)
1118
+ .map((row) => ({ label: usageText(row?.label, 80), value: usageText(row?.value, 120) }))
1119
+ .filter((row) => row.label && row.value);
1120
+ return rows.length ? { label: usageText(section.label, 80), rows } : null;
1121
+ }).filter(Boolean);
1122
+ const notes = (Array.isArray(card.notes) ? card.notes : []).slice(0, 6).map((note) => usageText(note?.text)).filter(Boolean);
1123
+ return {
1124
+ provider: toText(card.provider),
1125
+ label: usageText(card.label, 60) || toText(card.provider),
1126
+ status: toText(card.status) || 'unavailable',
1127
+ ...(usageText(card.planName, 80) ? { plan: usageText(card.planName, 80) } : {}),
1128
+ ...(usageText(card.message) ? { message: usageText(card.message) } : {}),
1129
+ source: toText(card.source) || null,
1130
+ capturedAt: toText(card.capturedAt) || null,
1131
+ ...(card.stale === true ? { stale: true } : {}),
1132
+ meters,
1133
+ ...(details.length ? { details } : {}),
1134
+ ...(notes.length ? { notes } : {}),
1135
+ };
1136
+ }
1137
+
1138
+ function usageLimitState(payload) {
1139
+ const account = isPlainObject(payload?.account) ? payload.account : null;
1140
+ return {
1141
+ account: account ? {
1142
+ status: toText(account.status) || null,
1143
+ window: toText(account.label) || toText(account.rateLimitType) || null,
1144
+ utilization: usageNumber(account.utilization),
1145
+ resetsAt: toText(account.resetsAt) || null,
1146
+ ...(account.isUsingOverage === true ? { usingOverage: true } : {}),
1147
+ observedAt: toText(account.observedAt) || null,
1148
+ } : null,
1149
+ pauses: (Array.isArray(payload?.pauses) ? payload.pauses : []).slice(0, 20).filter(isPlainObject).map((pause) => ({
1150
+ session: toText(pause.conversationId) || null,
1151
+ window: toText(pause.label) || toText(pause.rateLimitType) || null,
1152
+ resetsAt: toText(pause.resetsAt) || null,
1153
+ resumeAt: toText(pause.resumeAt) || null,
1154
+ auto: pause.auto === true,
1155
+ })),
1156
+ };
1157
+ }
1158
+
1159
+ /**
1160
+ * usage: what each provider plan of the relay has left (GET /api/usage, the
1161
+ * usage modal's data) and the Claude usage-limit state (GET
1162
+ * /api/usage-limit): numbers, plan names, reset times, states.
1163
+ */
1164
+ async function usageAction(ctx) {
1165
+ let report;
1166
+ try {
1167
+ report = await call(ctx, 'GET', '/api/usage', { timeoutMs: 30_000 });
1168
+ } catch (error) {
1169
+ if (isRemoteRelayError(error) && toText(error.code) === REMOTE_RELAY_ERROR_CODES.notFound) {
1170
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
1171
+ `Relay "${ctx.relay.name}" does not report plan usage (it runs an older OAR version).`);
1172
+ }
1173
+ throw error;
1174
+ }
1175
+ const cards = Array.isArray(report?.providers) ? report.providers : null;
1176
+ if (!cards) {
1177
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
1178
+ `Relay "${ctx.relay.name}" reports only the Copilot quota in an older shape; it needs an update for plan usage per provider.`);
1179
+ }
1180
+ const limit = await call(ctx, 'GET', '/api/usage-limit').catch(() => null);
1181
+ const providers = cards.slice(0, 12).map(usageCard).filter(Boolean);
1182
+ const set = providers.filter((card) => !['not-configured', 'unavailable'].includes(card.status)).map((card) => card.provider);
1183
+ const usageLimit = limit?.ok === true ? usageLimitState(limit) : null;
1184
+ return {
1185
+ summary: `usage → ${ctx.relay.name}: ${set.join(', ') || 'no provider'} reported${usageLimit?.pauses?.length ? `, ${usageLimit.pauses.length} paused at a usage limit` : ''}`,
1186
+ providers,
1187
+ usageLimit,
1188
+ ...(usageLimit ? {} : { note: 'The usage-limit state was not available there.' }),
1189
+ };
1190
+ }
1191
+
1054
1192
  async function relayInfo(ctx) {
1055
1193
  const [identity, catalog] = await Promise.all([
1056
1194
  call(ctx, 'GET', '/api/relay/identity').catch(() => null),
@@ -1110,7 +1248,8 @@ export function createRemoteRelayDispatcher({
1110
1248
  title: toText(row?.title),
1111
1249
  provider: toText(row?.runtimeProviderType) || 'github',
1112
1250
  model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
1113
- active: row?.activeTurn === true || !!workerIds?.has(id),
1251
+ active: row?.activeTurn === true || !!workerIds?.has(id) || isBusyState(row?.sessionState),
1252
+ ...sessionActivityFields(row),
1114
1253
  updatedAt: row?.updatedAt || null,
1115
1254
  messageCount: nonNegativeInt(row?.messageCount),
1116
1255
  cwd: toText(row?.currentWorkspaceRootPath) || null,
@@ -1120,6 +1259,36 @@ export function createRemoteRelayDispatcher({
1120
1259
  };
1121
1260
  }
1122
1261
 
1262
+ /**
1263
+ * `state` (running_turn | background_only | idle), `tasks[]` and
1264
+ * `lastActivityAt` of a session, from the relay's `sessionState` /
1265
+ * `sessionTasks`. A relay that predates them gives none.
1266
+ */
1267
+ function sessionActivityFields(row) {
1268
+ const state = toText(row?.sessionState);
1269
+ if (!SESSION_STATES.has(state)) return {};
1270
+ const tasks = (Array.isArray(row?.sessionTasks) ? row.sessionTasks : [])
1271
+ .filter(isPlainObject)
1272
+ .slice(0, 20)
1273
+ .map((task) => ({
1274
+ kind: toText(task.kind) || 'subagent',
1275
+ description: clipLine(task.description, 200),
1276
+ startedAt: task.startedAt || null,
1277
+ lastActivityAt: task.lastActivityAt || null,
1278
+ status: toText(task.status) || 'running',
1279
+ ...(Number.isInteger(task.exitCode) ? { exitCode: task.exitCode } : {}),
1280
+ ...(task.statusReported === false ? { statusReported: false } : {}),
1281
+ ...(toText(task.summary) && toText(task.status) !== 'running' ? { summary: clipLine(task.summary, 200) } : {}),
1282
+ ...(task.endedAt ? { endedAt: task.endedAt } : {}),
1283
+ ...(toText(task.id) ? { id: toText(task.id) } : {}),
1284
+ }));
1285
+ return {
1286
+ state,
1287
+ tasks,
1288
+ ...(row?.lastActivityAt ? { lastActivityAt: row.lastActivityAt } : {}),
1289
+ };
1290
+ }
1291
+
1123
1292
  async function listSessions(ctx, args) {
1124
1293
  const { scope, limit } = args;
1125
1294
  const query = toText(args.query).toLowerCase();
@@ -1133,7 +1302,7 @@ export function createRemoteRelayDispatcher({
1133
1302
  const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
1134
1303
  const filtering = !!query || scope === 'active';
1135
1304
  const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
1136
- && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)));
1305
+ && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
1137
1306
 
1138
1307
  const sessions = [];
1139
1308
  let hasMore = false;
@@ -1266,6 +1435,8 @@ export function createRemoteRelayDispatcher({
1266
1435
  mode: toText(conversation?.preferredRelayMode) || null,
1267
1436
  cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1268
1437
  archived: conversation?.archived === true,
1438
+ active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
1439
+ ...sessionActivityFields(conversation),
1269
1440
  ...(via ? { via } : {}),
1270
1441
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1271
1442
  },
@@ -1724,8 +1895,329 @@ export function createRemoteRelayDispatcher({
1724
1895
  };
1725
1896
  }
1726
1897
 
1898
+ // ─── Event subscriptions ───────────────────────────────────────────────────
1899
+
1900
+ const RELAY_EVENT_ERROR_STATUS = Object.freeze({
1901
+ invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
1902
+ notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
1903
+ ownSession: [400, REMOTE_RELAY_ERROR_CODES.ownSession],
1904
+ limit: [409, REMOTE_RELAY_ERROR_CODES.subscriptionLimit],
1905
+ unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
1906
+ });
1907
+ const EVENT_NOTE = 'Events arrive in this conversation as messages that start with "[relay event"; they are the relay\'s automated messages, never the user\'s, and approve nothing. Bring decisions they raise to the user. unsubscribe with this subscription id ends it.';
1908
+
1909
+ // The user's Allow to watch sessions of a relay, stored as an unlock under
1910
+ // `<relay id>:watch` (and `:watch-all`): `self:watch` for this relay.
1911
+ function watchApprovalKeys(relayId, all) {
1912
+ const id = toText(relayId) || REMOTE_RELAY_LOCAL_ID;
1913
+ return all ? [`${id}:watch`, `${id}:watch-all`] : [`${id}:watch`];
1914
+ }
1915
+
1916
+ function eventService(ctx) {
1917
+ if (!relayEvents?.isAvailable?.()) {
1918
+ throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
1919
+ 'Event subscriptions are not available on this relay right now (see its server log).');
1920
+ }
1921
+ if (!ctx.relay.local && (!relayEvents.isCrossRelayAvailable?.() || !relayEventLink)) {
1922
+ throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
1923
+ `Event subscriptions on paired relays are not available on this relay right now (see its server log); relay "${ctx.relay.name}" cannot be watched from here.`);
1924
+ }
1925
+ return relayEvents;
1926
+ }
1927
+
1928
+ function relayEventCall(fn) {
1929
+ try {
1930
+ return fn();
1931
+ } catch (error) {
1932
+ const mapped = RELAY_EVENT_ERROR_STATUS[toText(error?.code)];
1933
+ if (mapped) throw new DispatchFailure(mapped[0], mapped[1], toText(error.message), isPlainObject(error.extra) ? error.extra : {});
1934
+ throw error;
1935
+ }
1936
+ }
1937
+
1938
+ /** A call to a paired relay's event routes; an older relay without them answers a clear error. */
1939
+ async function relayEventRemoteCall(ctx, path, body) {
1940
+ try {
1941
+ return await call(ctx, 'POST', path, { body });
1942
+ } catch (error) {
1943
+ if (isMissingRelayEventRoute(error)) {
1944
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported, relayWithoutCrossRelayEventsText(ctx.relay.name));
1945
+ }
1946
+ throw error;
1947
+ }
1948
+ }
1949
+
1950
+ function hasApprovalKey(conversationId, key) {
1951
+ try {
1952
+ return repository?.hasUnlock?.(conversationId, key) === true;
1953
+ } catch {
1954
+ return false;
1955
+ }
1956
+ }
1957
+
1958
+ function recordApprovalKeys(conversationId, keys, messageId) {
1959
+ for (const key of keys) {
1960
+ try {
1961
+ repository?.recordUnlock?.(conversationId, key, messageId || null);
1962
+ } catch (error) {
1963
+ try {
1964
+ logger?.warn?.(`${LOG_PREFIX} watch approval not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
1965
+ } catch {}
1966
+ }
1967
+ }
1968
+ }
1969
+
1970
+ /**
1971
+ * The user's Allow for this conversation to watch sessions of a relay:
1972
+ * asked on its first subscribe there, and again for the first one to
1973
+ * "all" unless that was the one asked about. Parallel calls share the
1974
+ * open card.
1975
+ */
1976
+ async function ensureWatchApproval(ctx, { all, watch }) {
1977
+ const { conversationId } = ctx;
1978
+ const keys = watchApprovalKeys(ctx.relay.id, all);
1979
+ const remembered = () => keys.every((key) => hasApprovalKey(conversationId, key));
1980
+ if (remembered()) return { approved: true };
1981
+ const key = `${conversationId}:${ctx.relay.id}:${all ? 'all' : 'some'}`;
1982
+ const open = watchApprovalsByKey.get(key);
1983
+ if (open) return open;
1984
+ const asking = (async () => {
1985
+ const decision = await requestApproval({
1986
+ conversationId,
1987
+ callerContext: ctx.caller,
1988
+ relay: relayTarget(ctx.relay),
1989
+ action: 'subscribe',
1990
+ args: {},
1991
+ summary: `subscribe → ${ctx.relay.name}${all ? ': all sessions' : ''}`,
1992
+ signal: ctx.signal,
1993
+ watch: { ...watch, ...(ctx.relay.local ? {} : { relayName: ctx.relay.name }) },
1994
+ });
1995
+ if (decision !== true && decision?.approved !== true) {
1996
+ return {
1997
+ approved: false,
1998
+ code: toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied,
1999
+ error: toText(decision?.error) || 'The user did not allow this conversation to watch those sessions. Go on without it, or ask the user first.',
2000
+ };
2001
+ }
2002
+ recordApprovalKeys(conversationId, keys, ctx.caller?.processingRowId);
2003
+ return { approved: true };
2004
+ })();
2005
+ watchApprovalsByKey.set(key, asking);
2006
+ try {
2007
+ return await asking;
2008
+ } finally {
2009
+ watchApprovalsByKey.delete(key);
2010
+ }
2011
+ }
2012
+
2013
+ async function subscribeAction(ctx, args) {
2014
+ if (!ctx.relay.local) return subscribeRemoteAction(ctx, args);
2015
+ const service = eventService(ctx);
2016
+ const all = args.scope === 'all';
2017
+ const ids = all ? [] : args.sessions;
2018
+ // Refused before anyone is asked: the conversation itself, unknown ids.
2019
+ if (!all && ids.includes(ctx.conversationId)) {
2020
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
2021
+ `Session ${ctx.conversationId} is the conversation you are running in: it is never watched (your own turns would wake you).`,
2022
+ { session: ctx.conversationId });
2023
+ }
2024
+ const titles = [];
2025
+ for (const id of ids) {
2026
+ const page = await call(ctx, 'GET', sessionPath(id), { query: { limit: 1 } }).catch(() => null);
2027
+ if (!page || page.archived === true) {
2028
+ throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
2029
+ `No live session ${id} on this relay; use an id from list_sessions (archived sessions cannot be watched).`, { session: id });
2030
+ }
2031
+ titles.push({ id, title: toText(page.title) || null });
2032
+ }
2033
+ const events = Array.isArray(args.events) && args.events.length ? args.events : [...RELAY_EVENT_DEFAULT_TYPES];
2034
+ const decision = await ensureWatchApproval(ctx, {
2035
+ all,
2036
+ watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
2037
+ });
2038
+ if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2039
+ const subscription = relayEventCall(() => service.subscribe({
2040
+ watcherConversationId: ctx.conversationId,
2041
+ scope: all ? 'all' : 'sessions',
2042
+ sessionIds: ids,
2043
+ events: args.events || null,
2044
+ once: args.once === true,
2045
+ match: args.match || '',
2046
+ stalledMinutes: args.stalled_minutes ?? null,
2047
+ heartbeatMinutes: args.heartbeat_minutes ?? null,
2048
+ }));
2049
+ return {
2050
+ summary: `subscribe → ${ctx.relay.name}: ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${subscription.id})`,
2051
+ subscription,
2052
+ note: EVENT_NOTE,
2053
+ };
2054
+ }
2055
+
2056
+ /**
2057
+ * subscribe on a paired relay: that relay checks the request first (dry
2058
+ * run: sessions, limits, its inbound switch, its version), then the user
2059
+ * is asked here, then it stores the subscription and this relay its
2060
+ * mirror; the link fetches the events from then on.
2061
+ */
2062
+ async function subscribeRemoteAction(ctx, args) {
2063
+ const service = eventService(ctx);
2064
+ const all = args.scope === 'all';
2065
+ relayEventCall(() => service.checkWatcherCapacity(ctx.conversationId));
2066
+ const request = {
2067
+ watcher: { conversationId: ctx.conversationId, title: toText(ctx.caller?.title) || null },
2068
+ subscriber: { name: toText(await registry.selfName?.()) || null },
2069
+ scope: all ? 'all' : 'sessions',
2070
+ sessions: all ? [] : args.sessions,
2071
+ ...(args.events ? { events: args.events } : {}),
2072
+ once: args.once === true,
2073
+ ...(args.match ? { match: args.match } : {}),
2074
+ ...(args.stalled_minutes !== undefined ? { stalled_minutes: args.stalled_minutes } : {}),
2075
+ ...(args.heartbeat_minutes !== undefined ? { heartbeat_minutes: args.heartbeat_minutes } : {}),
2076
+ };
2077
+ const checked = await relayEventRemoteCall(ctx, RELAY_EVENT_REMOTE_SUBSCRIBE_PATH, { ...request, dry_run: true });
2078
+ const preview = isPlainObject(checked?.subscription) ? checked.subscription : {};
2079
+ const decision = await ensureWatchApproval(ctx, {
2080
+ all,
2081
+ watch: {
2082
+ kind: 'subscribe',
2083
+ all,
2084
+ sessions: all ? [] : (Array.isArray(preview.sessions) ? preview.sessions : args.sessions.map((id) => ({ id, title: null }))),
2085
+ events: Array.isArray(preview.events) ? preview.events : (args.events || [...RELAY_EVENT_DEFAULT_TYPES]),
2086
+ once: args.once === true,
2087
+ heartbeatMinutes: args.heartbeat_minutes ?? null,
2088
+ },
2089
+ });
2090
+ if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2091
+ const created = await relayEventRemoteCall(ctx, RELAY_EVENT_REMOTE_SUBSCRIBE_PATH, request);
2092
+ const remote = isPlainObject(created?.subscription) ? created.subscription : null;
2093
+ if (!toText(remote?.id)) {
2094
+ throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`, `Relay "${ctx.relay.name}" did not return the subscription.`);
2095
+ }
2096
+ let subscription;
2097
+ try {
2098
+ subscription = relayEventCall(() => service.addMirror({
2099
+ watcherConversationId: ctx.conversationId,
2100
+ relayId: ctx.relay.id,
2101
+ relayName: ctx.relay.name,
2102
+ remote,
2103
+ }));
2104
+ } catch (error) {
2105
+ // Not kept here: end it there too, so it does not wait for a fetch that never comes.
2106
+ await call(ctx, 'POST', RELAY_EVENT_REMOTE_PULL_PATH, {
2107
+ body: { after: Number(service.getRemoteLink(ctx.relay.id).cursor) || 0, subscriptions: [], ended: [remote.id] },
2108
+ }).catch(() => {});
2109
+ throw error;
2110
+ }
2111
+ void relayEventLink.fetchNow(ctx.relay.id);
2112
+ const count = all ? 'all sessions' : `${args.sessions.length} session${args.sessions.length === 1 ? '' : 's'}`;
2113
+ return {
2114
+ summary: `subscribe → ${ctx.relay.name}: ${count} (${subscription.id})`,
2115
+ subscription,
2116
+ note: `${EVENT_NOTE} This relay fetches them from relay "${ctx.relay.name}" every few seconds; their lines start with its name. If the link drops, what that relay kept comes as one replayed message once it answers again.`,
2117
+ };
2118
+ }
2119
+
2120
+ /** Waits (briefly) until the ends of these paired relays reached them: a note for each that did not answer. */
2121
+ async function deliverEnds(relayIds) {
2122
+ const notes = [];
2123
+ for (const relayId of new Set(relayIds)) {
2124
+ const result = await relayEventLink?.refresh?.(relayId, { waitMs: 10_000 }).catch(() => null);
2125
+ if (result?.ok) continue;
2126
+ const name = toText(registry.get?.(relayId)?.name) || relayId;
2127
+ notes.push(`Relay "${name}" did not answer (${toText(result?.error) || 'no answer'}); it learns of the end once it answers again, and nothing of it reaches this conversation meanwhile.`);
2128
+ }
2129
+ return notes;
2130
+ }
2131
+
2132
+ async function unsubscribeAction(ctx, args) {
2133
+ const service = eventService(ctx);
2134
+ const all = args.subscription_id === 'all';
2135
+ const ended = relayEventCall(() => service.unsubscribe({
2136
+ watcherConversationId: ctx.conversationId,
2137
+ subscriptionId: args.subscription_id,
2138
+ ...(all ? { relayId: ctx.relay.local ? REMOTE_RELAY_LOCAL_ID : ctx.relay.id } : {}),
2139
+ }));
2140
+ const notes = await deliverEnds(ended.filter((sub) => sub.remoteId).map((sub) => sub.relay));
2141
+ return {
2142
+ summary: `unsubscribe → ${ctx.relay.name}: ${ended.length} subscription${ended.length === 1 ? '' : 's'} ended`,
2143
+ ended: ended.map(({ pendingSync: _sync, ...sub }) => sub),
2144
+ ...(notes.length ? { note: notes.join(' ') } : {}),
2145
+ };
2146
+ }
2147
+
2148
+ function linkHealth(relayId) {
2149
+ const link = relayEvents.getRemoteLink?.(relayId) || {};
2150
+ const state = link.lost_at ? 'lost' : (link.first_fail_at ? 'failing' : 'ok');
2151
+ return {
2152
+ state,
2153
+ ...(link.last_ok_at ? { lastAnswerAt: link.last_ok_at } : {}),
2154
+ ...(state !== 'ok' && link.last_error ? { error: link.last_error } : {}),
2155
+ };
2156
+ }
2157
+
2158
+ async function listSubscriptionsAction(ctx, args) {
2159
+ const service = eventService(ctx);
2160
+ const watcher = toText(args.watcher) || ctx.conversationId;
2161
+ // A paired relay's state is fresh: fetched now (briefly waited for).
2162
+ if (!ctx.relay.local && service.mirrorsOf(ctx.relay.id).length) await relayEventLink.refresh(ctx.relay.id, { waitMs: 5_000 }).catch(() => {});
2163
+ const subscriptions = relayEventCall(() => service.listSubscriptions({ watcherConversationId: watcher }))
2164
+ .filter((sub) => ctx.relay.local || sub.relay === ctx.relay.id)
2165
+ .map(({ pendingSync, ...sub }) => (sub.remoteId
2166
+ ? { ...sub, ...(pendingSync ? { endPending: true } : {}), ...(sub.endedAt ? {} : { link: linkHealth(sub.relay) }) }
2167
+ : sub));
2168
+ const active = subscriptions.filter((subscription) => !subscription.endedAt).length;
2169
+ const notes = [];
2170
+ if (subscriptions.length > active) notes.push('Subscriptions with endedAt ended within the last hour (endReason says why).');
2171
+ if (subscriptions.some((sub) => sub.link && sub.link.state !== 'ok')) notes.push('A link that is not ok gets no events now; what that relay keeps meanwhile comes as one replayed message once it answers.');
2172
+ return {
2173
+ summary: `list_subscriptions → ${ctx.relay.name}${watcher === ctx.conversationId ? '' : ` session ${shortId(watcher)}`}: ${active} active`,
2174
+ watcher,
2175
+ subscriptions,
2176
+ ...(notes.length ? { note: notes.join(' ') } : {}),
2177
+ };
2178
+ }
2179
+
2180
+ async function transferSubscriptionsAction(ctx, args) {
2181
+ const service = eventService(ctx);
2182
+ const relayId = ctx.relay.local ? null : ctx.relay.id;
2183
+ const preview = relayEventCall(() => service.previewTransfer({ fromConversationId: args.from, toConversationId: ctx.conversationId, relayId }));
2184
+ // Always asked, also after this conversation's first-subscribe Allow:
2185
+ // the subscriptions of another conversation change hands.
2186
+ const decision = await requestApproval({
2187
+ conversationId: ctx.conversationId,
2188
+ callerContext: ctx.caller,
2189
+ relay: relayTarget(ctx.relay),
2190
+ action: 'transfer_subscriptions',
2191
+ args: {},
2192
+ summary: `transfer_subscriptions → ${ctx.relay.name}: ${preview.count} from session ${shortId(preview.from.id)}`,
2193
+ signal: ctx.signal,
2194
+ watch: { kind: 'transfer', from: preview.from, count: preview.count, all: preview.includesAll, relays: preview.relays },
2195
+ });
2196
+ if (decision !== true && decision?.approved !== true) {
2197
+ throw new DispatchFailure(403, toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied,
2198
+ toText(decision?.error) || 'The user did not allow the transfer; the subscriptions stay where they are.');
2199
+ }
2200
+ const result = relayEventCall(() => service.transferSubscriptions({ fromConversationId: args.from, toConversationId: ctx.conversationId, relayId }));
2201
+ // The Allow counts as this conversation's watch approval on every relay
2202
+ // among the moved subscriptions.
2203
+ const byRelay = new Map();
2204
+ for (const sub of preview.subscriptions) byRelay.set(sub.relay, byRelay.get(sub.relay) || sub.scope === 'all');
2205
+ for (const [relay, all] of byRelay) recordApprovalKeys(ctx.conversationId, watchApprovalKeys(relay, all), ctx.caller?.processingRowId);
2206
+ return {
2207
+ summary: `transfer_subscriptions → ${ctx.relay.name}: ${result.moved.length} moved from session ${shortId(result.from.id)}`,
2208
+ from: result.from,
2209
+ moved: result.moved,
2210
+ ...(result.ended.length ? { ended: result.ended, note: 'Subscriptions that watched only this conversation ended: it is never watched by itself.' } : {}),
2211
+ };
2212
+ }
2213
+
1727
2214
  const ACTIONS = Object.freeze({
2215
+ subscribe: subscribeAction,
2216
+ unsubscribe: unsubscribeAction,
2217
+ list_subscriptions: listSubscriptionsAction,
2218
+ transfer_subscriptions: transferSubscriptionsAction,
1728
2219
  relay_info: relayInfo,
2220
+ usage: usageAction,
1729
2221
  list_sessions: listSessions,
1730
2222
  read_session: readSession,
1731
2223
  wait: waitAction,
@@ -146,6 +146,10 @@ export function createRemoteRelayRegistry({
146
146
  setIntervalImpl = setInterval,
147
147
  clearIntervalImpl = clearInterval,
148
148
  randomUUID = nodeRandomUUID,
149
+ // Called with the removed entry (address and token still in it), after it
150
+ // is gone from the list: the event link tells that relay its
151
+ // subscriptions ended.
152
+ onRemove = () => {},
149
153
  } = {}) {
150
154
  let cachedInstanceId = '';
151
155
  let healthTimer = null;
@@ -446,14 +450,20 @@ export function createRemoteRelayRegistry({
446
450
  function remove(id) {
447
451
  const key = toText(id);
448
452
  const entries = readList();
453
+ const removed = entries.find((entry) => entry.id === key) || null;
449
454
  const remaining = entries.filter((entry) => entry.id !== key);
450
- if (!key || remaining.length === entries.length) return invalid('Unknown remote relay', 404);
455
+ if (!key || !removed) return invalid('Unknown remote relay', 404);
451
456
  writeList(remaining);
452
457
  try {
453
458
  repository?.forgetRelay?.(key);
454
459
  } catch (error) {
455
460
  logger?.warn?.(`[remote-relays] forgetting unlocks failed: ${error?.message || error}`);
456
461
  }
462
+ try {
463
+ onRemove(removed);
464
+ } catch (error) {
465
+ logger?.warn?.(`[remote-relays] removal hook failed: ${error?.message || error}`);
466
+ }
457
467
  emitChange();
458
468
  return { ok: true };
459
469
  }