@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
@@ -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),
@@ -1100,7 +1238,21 @@ export function createRemoteRelayDispatcher({
1100
1238
  }
1101
1239
  }
1102
1240
 
1103
- function mapSession(row, workerIds, ctx = null) {
1241
+ /** The session group of a listed row: id, display name and the row's role. */
1242
+ function sessionGroupField(row, groups) {
1243
+ const groupId = toText(row?.groupId);
1244
+ if (!groupId) return {};
1245
+ const info = isPlainObject(groups?.[groupId]) ? groups[groupId] : null;
1246
+ return {
1247
+ group: {
1248
+ id: groupId,
1249
+ name: toText(info?.displayName) || toText(info?.name) || null,
1250
+ role: toText(row?.groupRole) === 'head' ? 'head' : 'member',
1251
+ },
1252
+ };
1253
+ }
1254
+
1255
+ function mapSession(row, workerIds, ctx = null, groups = null) {
1104
1256
  const id = toText(row?.id);
1105
1257
  const via = toText(row?.origin?.relayName);
1106
1258
  // A session an agent of that relay created there, and from which session.
@@ -1110,13 +1262,47 @@ export function createRemoteRelayDispatcher({
1110
1262
  title: toText(row?.title),
1111
1263
  provider: toText(row?.runtimeProviderType) || 'github',
1112
1264
  model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
1113
- active: row?.activeTurn === true || !!workerIds?.has(id),
1265
+ active: row?.activeTurn === true || !!workerIds?.has(id) || isBusyState(row?.sessionState),
1266
+ ...sessionActivityFields(row),
1114
1267
  updatedAt: row?.updatedAt || null,
1115
1268
  messageCount: nonNegativeInt(row?.messageCount),
1116
1269
  cwd: toText(row?.currentWorkspaceRootPath) || null,
1117
1270
  ...(via ? { via } : {}),
1118
1271
  ...(createdBy ? { createdBy } : {}),
1119
1272
  ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
1273
+ // The session group it sits in (sessions an agent started here are
1274
+ // grouped under that agent's session; the owner can group by hand).
1275
+ ...sessionGroupField(row, groups),
1276
+ };
1277
+ }
1278
+
1279
+ /**
1280
+ * `state` (running_turn | background_only | idle), `tasks[]` and
1281
+ * `lastActivityAt` of a session, from the relay's `sessionState` /
1282
+ * `sessionTasks`. A relay that predates them gives none.
1283
+ */
1284
+ function sessionActivityFields(row) {
1285
+ const state = toText(row?.sessionState);
1286
+ if (!SESSION_STATES.has(state)) return {};
1287
+ const tasks = (Array.isArray(row?.sessionTasks) ? row.sessionTasks : [])
1288
+ .filter(isPlainObject)
1289
+ .slice(0, 20)
1290
+ .map((task) => ({
1291
+ kind: toText(task.kind) || 'subagent',
1292
+ description: clipLine(task.description, 200),
1293
+ startedAt: task.startedAt || null,
1294
+ lastActivityAt: task.lastActivityAt || null,
1295
+ status: toText(task.status) || 'running',
1296
+ ...(Number.isInteger(task.exitCode) ? { exitCode: task.exitCode } : {}),
1297
+ ...(task.statusReported === false ? { statusReported: false } : {}),
1298
+ ...(toText(task.summary) && toText(task.status) !== 'running' ? { summary: clipLine(task.summary, 200) } : {}),
1299
+ ...(task.endedAt ? { endedAt: task.endedAt } : {}),
1300
+ ...(toText(task.id) ? { id: toText(task.id) } : {}),
1301
+ }));
1302
+ return {
1303
+ state,
1304
+ tasks,
1305
+ ...(row?.lastActivityAt ? { lastActivityAt: row.lastActivityAt } : {}),
1120
1306
  };
1121
1307
  }
1122
1308
 
@@ -1127,15 +1313,16 @@ export function createRemoteRelayDispatcher({
1127
1313
  // pages, all = the whole list page by page (only it honours the cursor).
1128
1314
  let cursor = null;
1129
1315
  if (scope === 'all' && args.cursor) {
1130
- cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt']);
1316
+ cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
1131
1317
  if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
1132
1318
  }
1133
1319
  const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
1134
1320
  const filtering = !!query || scope === 'active';
1135
1321
  const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
1136
- && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)));
1322
+ && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
1137
1323
 
1138
1324
  const sessions = [];
1325
+ const seen = new Set();
1139
1326
  let hasMore = false;
1140
1327
  let scanned = 0;
1141
1328
  for (let page = 0; page < (filtering ? LIST_SCAN_PAGES : 1); page += 1) {
@@ -1144,18 +1331,32 @@ export function createRemoteRelayDispatcher({
1144
1331
  limit: filtering ? LIST_PAGE_MAX : limit,
1145
1332
  beforeConversationId: cursor?.beforeConversationId,
1146
1333
  beforeUpdatedAt: cursor?.beforeUpdatedAt,
1334
+ beforeGroupId: cursor?.beforeGroupId,
1147
1335
  },
1148
1336
  });
1149
1337
  const rows = Array.isArray(response?.conversations) ? response.conversations : [];
1338
+ const groups = isPlainObject(response?.groups) ? response.groups : null;
1150
1339
  let consumed = 0;
1151
1340
  for (const row of rows) {
1152
1341
  consumed += 1;
1153
- if (keep(row)) sessions.push(mapSession(row, workerIds, ctx));
1342
+ // A group served again whole (its cursor row vanished) repeats rows.
1343
+ if (seen.has(toText(row?.id))) continue;
1344
+ seen.add(toText(row?.id));
1345
+ if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
1154
1346
  if (sessions.length >= limit) break;
1155
1347
  }
1156
1348
  scanned += consumed;
1157
1349
  const last = rows[consumed - 1];
1158
- if (last) cursor = { beforeConversationId: toText(last.id), beforeUpdatedAt: toText(last.updatedAt) };
1350
+ // The row's sort time (its group's newest row, `activityAt`) and its
1351
+ // group, so the next page continues from the right place even if the
1352
+ // row is archived or deleted before then.
1353
+ if (last) {
1354
+ cursor = {
1355
+ beforeConversationId: toText(last.id),
1356
+ beforeUpdatedAt: toText(last.activityAt) || toText(last.updatedAt),
1357
+ ...(toText(last.groupId) ? { beforeGroupId: toText(last.groupId) } : {}),
1358
+ };
1359
+ }
1159
1360
  hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
1160
1361
  if (sessions.length >= limit || !hasMore || !rows.length) break;
1161
1362
  }
@@ -1236,7 +1437,9 @@ export function createRemoteRelayDispatcher({
1236
1437
  if (!before) throw invalidInput('before is not an olderCursor from read_session');
1237
1438
  }
1238
1439
  const conversation = await call(ctx, 'GET', sessionPath(args.session), {
1239
- query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp },
1440
+ // groupMembers: the session group's live members; the relay gives them
1441
+ // to its own sessions only.
1442
+ query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
1240
1443
  });
1241
1444
  const conversationId = toText(conversation?.id) || args.session;
1242
1445
  const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
@@ -1256,6 +1459,23 @@ export function createRemoteRelayDispatcher({
1256
1459
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1257
1460
  const via = toText(conversation?.origin?.relayName);
1258
1461
  const inFlight = progressSnapshot(conversation?.inFlight);
1462
+ // Its session group, with the other members and what each is doing.
1463
+ const groupInfo = isPlainObject(conversation?.group) ? conversation.group : null;
1464
+ const group = groupInfo && toText(groupInfo.id) ? {
1465
+ id: toText(groupInfo.id),
1466
+ name: toText(groupInfo.displayName) || toText(groupInfo.name) || null,
1467
+ role: toText(groupInfo.role) === 'head' ? 'head' : 'member',
1468
+ members: (Array.isArray(groupInfo.members) ? groupInfo.members : [])
1469
+ .filter(isPlainObject)
1470
+ .slice(0, 50)
1471
+ .map((member) => ({
1472
+ id: toText(member.id),
1473
+ title: toText(member.title),
1474
+ role: toText(member.role) === 'head' ? 'head' : 'member',
1475
+ state: SESSION_STATES.has(toText(member.state)) ? toText(member.state) : 'idle',
1476
+ ...(member.archived === true ? { archived: true } : {}),
1477
+ })),
1478
+ } : null;
1259
1479
  return {
1260
1480
  summary: `read_session → ${ctx.relay.name} session ${shortId(conversationId)}: ${messages.length} message${messages.length === 1 ? '' : 's'}${inFlight ? ', turn running' : ''}${pendingQuestions.length ? `, ${pendingQuestions.length} question${pendingQuestions.length === 1 ? '' : 's'} pending` : ''}`,
1261
1481
  session: {
@@ -1266,7 +1486,10 @@ export function createRemoteRelayDispatcher({
1266
1486
  mode: toText(conversation?.preferredRelayMode) || null,
1267
1487
  cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1268
1488
  archived: conversation?.archived === true,
1489
+ active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
1490
+ ...sessionActivityFields(conversation),
1269
1491
  ...(via ? { via } : {}),
1492
+ ...(group ? { group } : {}),
1270
1493
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1271
1494
  },
1272
1495
  messages,
@@ -1724,8 +1947,329 @@ export function createRemoteRelayDispatcher({
1724
1947
  };
1725
1948
  }
1726
1949
 
1950
+ // ─── Event subscriptions ───────────────────────────────────────────────────
1951
+
1952
+ const RELAY_EVENT_ERROR_STATUS = Object.freeze({
1953
+ invalid: [400, REMOTE_RELAY_ERROR_CODES.invalidInput],
1954
+ notFound: [404, REMOTE_RELAY_ERROR_CODES.notFound],
1955
+ ownSession: [400, REMOTE_RELAY_ERROR_CODES.ownSession],
1956
+ limit: [409, REMOTE_RELAY_ERROR_CODES.subscriptionLimit],
1957
+ unavailable: [503, REMOTE_RELAY_ERROR_CODES.unsupported],
1958
+ });
1959
+ 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.';
1960
+
1961
+ // The user's Allow to watch sessions of a relay, stored as an unlock under
1962
+ // `<relay id>:watch` (and `:watch-all`): `self:watch` for this relay.
1963
+ function watchApprovalKeys(relayId, all) {
1964
+ const id = toText(relayId) || REMOTE_RELAY_LOCAL_ID;
1965
+ return all ? [`${id}:watch`, `${id}:watch-all`] : [`${id}:watch`];
1966
+ }
1967
+
1968
+ function eventService(ctx) {
1969
+ if (!relayEvents?.isAvailable?.()) {
1970
+ throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
1971
+ 'Event subscriptions are not available on this relay right now (see its server log).');
1972
+ }
1973
+ if (!ctx.relay.local && (!relayEvents.isCrossRelayAvailable?.() || !relayEventLink)) {
1974
+ throw new DispatchFailure(503, REMOTE_RELAY_ERROR_CODES.unsupported,
1975
+ `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.`);
1976
+ }
1977
+ return relayEvents;
1978
+ }
1979
+
1980
+ function relayEventCall(fn) {
1981
+ try {
1982
+ return fn();
1983
+ } catch (error) {
1984
+ const mapped = RELAY_EVENT_ERROR_STATUS[toText(error?.code)];
1985
+ if (mapped) throw new DispatchFailure(mapped[0], mapped[1], toText(error.message), isPlainObject(error.extra) ? error.extra : {});
1986
+ throw error;
1987
+ }
1988
+ }
1989
+
1990
+ /** A call to a paired relay's event routes; an older relay without them answers a clear error. */
1991
+ async function relayEventRemoteCall(ctx, path, body) {
1992
+ try {
1993
+ return await call(ctx, 'POST', path, { body });
1994
+ } catch (error) {
1995
+ if (isMissingRelayEventRoute(error)) {
1996
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported, relayWithoutCrossRelayEventsText(ctx.relay.name));
1997
+ }
1998
+ throw error;
1999
+ }
2000
+ }
2001
+
2002
+ function hasApprovalKey(conversationId, key) {
2003
+ try {
2004
+ return repository?.hasUnlock?.(conversationId, key) === true;
2005
+ } catch {
2006
+ return false;
2007
+ }
2008
+ }
2009
+
2010
+ function recordApprovalKeys(conversationId, keys, messageId) {
2011
+ for (const key of keys) {
2012
+ try {
2013
+ repository?.recordUnlock?.(conversationId, key, messageId || null);
2014
+ } catch (error) {
2015
+ try {
2016
+ logger?.warn?.(`${LOG_PREFIX} watch approval not stored conv=${shortId(conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
2017
+ } catch {}
2018
+ }
2019
+ }
2020
+ }
2021
+
2022
+ /**
2023
+ * The user's Allow for this conversation to watch sessions of a relay:
2024
+ * asked on its first subscribe there, and again for the first one to
2025
+ * "all" unless that was the one asked about. Parallel calls share the
2026
+ * open card.
2027
+ */
2028
+ async function ensureWatchApproval(ctx, { all, watch }) {
2029
+ const { conversationId } = ctx;
2030
+ const keys = watchApprovalKeys(ctx.relay.id, all);
2031
+ const remembered = () => keys.every((key) => hasApprovalKey(conversationId, key));
2032
+ if (remembered()) return { approved: true };
2033
+ const key = `${conversationId}:${ctx.relay.id}:${all ? 'all' : 'some'}`;
2034
+ const open = watchApprovalsByKey.get(key);
2035
+ if (open) return open;
2036
+ const asking = (async () => {
2037
+ const decision = await requestApproval({
2038
+ conversationId,
2039
+ callerContext: ctx.caller,
2040
+ relay: relayTarget(ctx.relay),
2041
+ action: 'subscribe',
2042
+ args: {},
2043
+ summary: `subscribe → ${ctx.relay.name}${all ? ': all sessions' : ''}`,
2044
+ signal: ctx.signal,
2045
+ watch: { ...watch, ...(ctx.relay.local ? {} : { relayName: ctx.relay.name }) },
2046
+ });
2047
+ if (decision !== true && decision?.approved !== true) {
2048
+ return {
2049
+ approved: false,
2050
+ code: toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied,
2051
+ error: toText(decision?.error) || 'The user did not allow this conversation to watch those sessions. Go on without it, or ask the user first.',
2052
+ };
2053
+ }
2054
+ recordApprovalKeys(conversationId, keys, ctx.caller?.processingRowId);
2055
+ return { approved: true };
2056
+ })();
2057
+ watchApprovalsByKey.set(key, asking);
2058
+ try {
2059
+ return await asking;
2060
+ } finally {
2061
+ watchApprovalsByKey.delete(key);
2062
+ }
2063
+ }
2064
+
2065
+ async function subscribeAction(ctx, args) {
2066
+ if (!ctx.relay.local) return subscribeRemoteAction(ctx, args);
2067
+ const service = eventService(ctx);
2068
+ const all = args.scope === 'all';
2069
+ const ids = all ? [] : args.sessions;
2070
+ // Refused before anyone is asked: the conversation itself, unknown ids.
2071
+ if (!all && ids.includes(ctx.conversationId)) {
2072
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
2073
+ `Session ${ctx.conversationId} is the conversation you are running in: it is never watched (your own turns would wake you).`,
2074
+ { session: ctx.conversationId });
2075
+ }
2076
+ const titles = [];
2077
+ for (const id of ids) {
2078
+ const page = await call(ctx, 'GET', sessionPath(id), { query: { limit: 1 } }).catch(() => null);
2079
+ if (!page || page.archived === true) {
2080
+ throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
2081
+ `No live session ${id} on this relay; use an id from list_sessions (archived sessions cannot be watched).`, { session: id });
2082
+ }
2083
+ titles.push({ id, title: toText(page.title) || null });
2084
+ }
2085
+ const events = Array.isArray(args.events) && args.events.length ? args.events : [...RELAY_EVENT_DEFAULT_TYPES];
2086
+ const decision = await ensureWatchApproval(ctx, {
2087
+ all,
2088
+ watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
2089
+ });
2090
+ if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2091
+ const subscription = relayEventCall(() => service.subscribe({
2092
+ watcherConversationId: ctx.conversationId,
2093
+ scope: all ? 'all' : 'sessions',
2094
+ sessionIds: ids,
2095
+ events: args.events || null,
2096
+ once: args.once === true,
2097
+ match: args.match || '',
2098
+ stalledMinutes: args.stalled_minutes ?? null,
2099
+ heartbeatMinutes: args.heartbeat_minutes ?? null,
2100
+ }));
2101
+ return {
2102
+ summary: `subscribe → ${ctx.relay.name}: ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${subscription.id})`,
2103
+ subscription,
2104
+ note: EVENT_NOTE,
2105
+ };
2106
+ }
2107
+
2108
+ /**
2109
+ * subscribe on a paired relay: that relay checks the request first (dry
2110
+ * run: sessions, limits, its inbound switch, its version), then the user
2111
+ * is asked here, then it stores the subscription and this relay its
2112
+ * mirror; the link fetches the events from then on.
2113
+ */
2114
+ async function subscribeRemoteAction(ctx, args) {
2115
+ const service = eventService(ctx);
2116
+ const all = args.scope === 'all';
2117
+ relayEventCall(() => service.checkWatcherCapacity(ctx.conversationId));
2118
+ const request = {
2119
+ watcher: { conversationId: ctx.conversationId, title: toText(ctx.caller?.title) || null },
2120
+ subscriber: { name: toText(await registry.selfName?.()) || null },
2121
+ scope: all ? 'all' : 'sessions',
2122
+ sessions: all ? [] : args.sessions,
2123
+ ...(args.events ? { events: args.events } : {}),
2124
+ once: args.once === true,
2125
+ ...(args.match ? { match: args.match } : {}),
2126
+ ...(args.stalled_minutes !== undefined ? { stalled_minutes: args.stalled_minutes } : {}),
2127
+ ...(args.heartbeat_minutes !== undefined ? { heartbeat_minutes: args.heartbeat_minutes } : {}),
2128
+ };
2129
+ const checked = await relayEventRemoteCall(ctx, RELAY_EVENT_REMOTE_SUBSCRIBE_PATH, { ...request, dry_run: true });
2130
+ const preview = isPlainObject(checked?.subscription) ? checked.subscription : {};
2131
+ const decision = await ensureWatchApproval(ctx, {
2132
+ all,
2133
+ watch: {
2134
+ kind: 'subscribe',
2135
+ all,
2136
+ sessions: all ? [] : (Array.isArray(preview.sessions) ? preview.sessions : args.sessions.map((id) => ({ id, title: null }))),
2137
+ events: Array.isArray(preview.events) ? preview.events : (args.events || [...RELAY_EVENT_DEFAULT_TYPES]),
2138
+ once: args.once === true,
2139
+ heartbeatMinutes: args.heartbeat_minutes ?? null,
2140
+ },
2141
+ });
2142
+ if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2143
+ const created = await relayEventRemoteCall(ctx, RELAY_EVENT_REMOTE_SUBSCRIBE_PATH, request);
2144
+ const remote = isPlainObject(created?.subscription) ? created.subscription : null;
2145
+ if (!toText(remote?.id)) {
2146
+ throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`, `Relay "${ctx.relay.name}" did not return the subscription.`);
2147
+ }
2148
+ let subscription;
2149
+ try {
2150
+ subscription = relayEventCall(() => service.addMirror({
2151
+ watcherConversationId: ctx.conversationId,
2152
+ relayId: ctx.relay.id,
2153
+ relayName: ctx.relay.name,
2154
+ remote,
2155
+ }));
2156
+ } catch (error) {
2157
+ // Not kept here: end it there too, so it does not wait for a fetch that never comes.
2158
+ await call(ctx, 'POST', RELAY_EVENT_REMOTE_PULL_PATH, {
2159
+ body: { after: Number(service.getRemoteLink(ctx.relay.id).cursor) || 0, subscriptions: [], ended: [remote.id] },
2160
+ }).catch(() => {});
2161
+ throw error;
2162
+ }
2163
+ void relayEventLink.fetchNow(ctx.relay.id);
2164
+ const count = all ? 'all sessions' : `${args.sessions.length} session${args.sessions.length === 1 ? '' : 's'}`;
2165
+ return {
2166
+ summary: `subscribe → ${ctx.relay.name}: ${count} (${subscription.id})`,
2167
+ subscription,
2168
+ 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.`,
2169
+ };
2170
+ }
2171
+
2172
+ /** Waits (briefly) until the ends of these paired relays reached them: a note for each that did not answer. */
2173
+ async function deliverEnds(relayIds) {
2174
+ const notes = [];
2175
+ for (const relayId of new Set(relayIds)) {
2176
+ const result = await relayEventLink?.refresh?.(relayId, { waitMs: 10_000 }).catch(() => null);
2177
+ if (result?.ok) continue;
2178
+ const name = toText(registry.get?.(relayId)?.name) || relayId;
2179
+ 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.`);
2180
+ }
2181
+ return notes;
2182
+ }
2183
+
2184
+ async function unsubscribeAction(ctx, args) {
2185
+ const service = eventService(ctx);
2186
+ const all = args.subscription_id === 'all';
2187
+ const ended = relayEventCall(() => service.unsubscribe({
2188
+ watcherConversationId: ctx.conversationId,
2189
+ subscriptionId: args.subscription_id,
2190
+ ...(all ? { relayId: ctx.relay.local ? REMOTE_RELAY_LOCAL_ID : ctx.relay.id } : {}),
2191
+ }));
2192
+ const notes = await deliverEnds(ended.filter((sub) => sub.remoteId).map((sub) => sub.relay));
2193
+ return {
2194
+ summary: `unsubscribe → ${ctx.relay.name}: ${ended.length} subscription${ended.length === 1 ? '' : 's'} ended`,
2195
+ ended: ended.map(({ pendingSync: _sync, ...sub }) => sub),
2196
+ ...(notes.length ? { note: notes.join(' ') } : {}),
2197
+ };
2198
+ }
2199
+
2200
+ function linkHealth(relayId) {
2201
+ const link = relayEvents.getRemoteLink?.(relayId) || {};
2202
+ const state = link.lost_at ? 'lost' : (link.first_fail_at ? 'failing' : 'ok');
2203
+ return {
2204
+ state,
2205
+ ...(link.last_ok_at ? { lastAnswerAt: link.last_ok_at } : {}),
2206
+ ...(state !== 'ok' && link.last_error ? { error: link.last_error } : {}),
2207
+ };
2208
+ }
2209
+
2210
+ async function listSubscriptionsAction(ctx, args) {
2211
+ const service = eventService(ctx);
2212
+ const watcher = toText(args.watcher) || ctx.conversationId;
2213
+ // A paired relay's state is fresh: fetched now (briefly waited for).
2214
+ if (!ctx.relay.local && service.mirrorsOf(ctx.relay.id).length) await relayEventLink.refresh(ctx.relay.id, { waitMs: 5_000 }).catch(() => {});
2215
+ const subscriptions = relayEventCall(() => service.listSubscriptions({ watcherConversationId: watcher }))
2216
+ .filter((sub) => ctx.relay.local || sub.relay === ctx.relay.id)
2217
+ .map(({ pendingSync, ...sub }) => (sub.remoteId
2218
+ ? { ...sub, ...(pendingSync ? { endPending: true } : {}), ...(sub.endedAt ? {} : { link: linkHealth(sub.relay) }) }
2219
+ : sub));
2220
+ const active = subscriptions.filter((subscription) => !subscription.endedAt).length;
2221
+ const notes = [];
2222
+ if (subscriptions.length > active) notes.push('Subscriptions with endedAt ended within the last hour (endReason says why).');
2223
+ 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.');
2224
+ return {
2225
+ summary: `list_subscriptions → ${ctx.relay.name}${watcher === ctx.conversationId ? '' : ` session ${shortId(watcher)}`}: ${active} active`,
2226
+ watcher,
2227
+ subscriptions,
2228
+ ...(notes.length ? { note: notes.join(' ') } : {}),
2229
+ };
2230
+ }
2231
+
2232
+ async function transferSubscriptionsAction(ctx, args) {
2233
+ const service = eventService(ctx);
2234
+ const relayId = ctx.relay.local ? null : ctx.relay.id;
2235
+ const preview = relayEventCall(() => service.previewTransfer({ fromConversationId: args.from, toConversationId: ctx.conversationId, relayId }));
2236
+ // Always asked, also after this conversation's first-subscribe Allow:
2237
+ // the subscriptions of another conversation change hands.
2238
+ const decision = await requestApproval({
2239
+ conversationId: ctx.conversationId,
2240
+ callerContext: ctx.caller,
2241
+ relay: relayTarget(ctx.relay),
2242
+ action: 'transfer_subscriptions',
2243
+ args: {},
2244
+ summary: `transfer_subscriptions → ${ctx.relay.name}: ${preview.count} from session ${shortId(preview.from.id)}`,
2245
+ signal: ctx.signal,
2246
+ watch: { kind: 'transfer', from: preview.from, count: preview.count, all: preview.includesAll, relays: preview.relays },
2247
+ });
2248
+ if (decision !== true && decision?.approved !== true) {
2249
+ throw new DispatchFailure(403, toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied,
2250
+ toText(decision?.error) || 'The user did not allow the transfer; the subscriptions stay where they are.');
2251
+ }
2252
+ const result = relayEventCall(() => service.transferSubscriptions({ fromConversationId: args.from, toConversationId: ctx.conversationId, relayId }));
2253
+ // The Allow counts as this conversation's watch approval on every relay
2254
+ // among the moved subscriptions.
2255
+ const byRelay = new Map();
2256
+ for (const sub of preview.subscriptions) byRelay.set(sub.relay, byRelay.get(sub.relay) || sub.scope === 'all');
2257
+ for (const [relay, all] of byRelay) recordApprovalKeys(ctx.conversationId, watchApprovalKeys(relay, all), ctx.caller?.processingRowId);
2258
+ return {
2259
+ summary: `transfer_subscriptions → ${ctx.relay.name}: ${result.moved.length} moved from session ${shortId(result.from.id)}`,
2260
+ from: result.from,
2261
+ moved: result.moved,
2262
+ ...(result.ended.length ? { ended: result.ended, note: 'Subscriptions that watched only this conversation ended: it is never watched by itself.' } : {}),
2263
+ };
2264
+ }
2265
+
1727
2266
  const ACTIONS = Object.freeze({
2267
+ subscribe: subscribeAction,
2268
+ unsubscribe: unsubscribeAction,
2269
+ list_subscriptions: listSubscriptionsAction,
2270
+ transfer_subscriptions: transferSubscriptionsAction,
1728
2271
  relay_info: relayInfo,
2272
+ usage: usageAction,
1729
2273
  list_sessions: listSessions,
1730
2274
  read_session: readSession,
1731
2275
  wait: waitAction,