@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -0,0 +1,1500 @@
1
+ 'use strict';
2
+
3
+ import { randomUUID as nodeRandomUUID } from 'crypto';
4
+
5
+ import {
6
+ REMOTE_RELAY_APPROVAL_MODES,
7
+ REMOTE_RELAY_ERROR_CODES,
8
+ REMOTE_RELAY_LIMITS,
9
+ REMOTE_RELAY_PROVIDERS,
10
+ isRemoteRelayApprovalQuestion,
11
+ isRemoteRelayWriteAction,
12
+ normalizeRemoteRelayOrigin,
13
+ remoteConversationUrl,
14
+ remoteRelayPermissionAllows,
15
+ REMOTE_RELAY_ACTION_PERMISSION,
16
+ stripRemotePromptHeader,
17
+ summarizeRemoteRelayCall,
18
+ validateRemoteRelayToolInput,
19
+ withRemotePromptHeader,
20
+ } from '../../shared/remote-relay-contract.mjs';
21
+ import { schemaFields } from '../../shared/question-schema.mjs';
22
+
23
+ // The one place a `remote_relay` tool call is decided and carried out (plan
24
+ // §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
25
+ // handler calls dispatch(). In order:
26
+ //
27
+ // validate → list_relays (local, no gate) → resolve the relay → mention gate
28
+ // → permission → hop limit → rate limit → approval (write actions in
29
+ // ask/plan) → execute against the remote relay's existing API
30
+ //
31
+ // The remote is reached only through the outbound client (auth, origin and
32
+ // hop headers are its job). Reply detection reads the remote's own transcript:
33
+ // the assistant row whose sourceMessageId is the message we queued, with the
34
+ // steering settle kinds (folded / absorbed / stopped) resolved the way the
35
+ // remote's UI resolves them.
36
+ //
37
+ // Nothing here logs prompt or reply text: a write action leaves one line with
38
+ // the action, the relay, the remote session and a result code.
39
+
40
+ const LOG_PREFIX = '[remote-relays]';
41
+
42
+ // An unexpected failure inside this relay.
43
+ export const REMOTE_RELAY_INTERNAL_ERROR_CODE = REMOTE_RELAY_ERROR_CODES.internal;
44
+
45
+ const RATE_WINDOW_MS = 60_000;
46
+ // Rows after the sent message the wait loop reads per poll. A reply sits a few
47
+ // rows behind its prompt even in a busy, steered session.
48
+ const WAIT_PAGE_LIMIT = 30;
49
+ // The most messages GET /api/conversation/:id returns in one page.
50
+ const HISTORY_PAGE_MAX = 100;
51
+ // When a page after the sent message comes back full, the wait reads on, up to
52
+ // this many pages (a late wait in a busy session: the reply is further back).
53
+ const AFTER_SCAN_PAGES = 10;
54
+ // /api/conversations caps a page at 100; a filtered listing (query or active)
55
+ // scans at most this many pages to fill one page of results.
56
+ const LIST_PAGE_MAX = 100;
57
+ const LIST_SCAN_PAGES = 5;
58
+ const PROGRESS_TEXT_MAX = 600;
59
+ // How long a session that worked in the background must stay quiet (no task,
60
+ // no turn in flight or queued) before its last follow-up counts as the end.
61
+ const FOLLOW_UP_SETTLE_MS = 10_000;
62
+ const PROGRESS_ACTIVITY_COUNT = 5;
63
+ const ACTIVITY_LINE_MAX = 300;
64
+ const ACTIVITY_LINES_PER_MESSAGE = 20;
65
+ const REPLY_TEXT_MAX = REMOTE_RELAY_LIMITS.perMessageChars * 2;
66
+ // A message that would only fit as a stub is dropped instead (read_session).
67
+ const MIN_TRIMMED_MESSAGE_CHARS = 200;
68
+ const TITLE_MAX = 80;
69
+
70
+ // The provider settings routes a relay serves; github (Copilot) has none with
71
+ // models — its catalogue is /api/models plus /api/status defaultModel.
72
+ const SETTINGS_PROVIDERS = Object.freeze(['claude', 'cursor', 'grok', 'openai']);
73
+ // How /api/models `providersByModel` names each provider.
74
+ const CATALOG_PROVIDER_LABELS = Object.freeze({
75
+ github: 'github-copilot',
76
+ openai: 'openai-byok',
77
+ claude: 'claude',
78
+ cursor: 'cursor',
79
+ grok: 'grok',
80
+ });
81
+ // Worker statuses that mean "working on something right now" (scope active).
82
+ const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
83
+ // Terminal turn failures are stored as ordinary assistant rows; these are the
84
+ // texts the relay writes for them (buildTerminalFailureTextForChat and the two
85
+ // recovery-limit paths in messages-routes / server-runtime). Each is the whole
86
+ // reply and opens with its marker, or (the stable-code form) with a short
87
+ // message followed by "Error code: relay.<code>." in the same paragraph. So a
88
+ // marker counts only there: a normal reply that quotes a relay error further
89
+ // down is a reply, not a failure.
90
+ const FAILURE_TEXT_PATTERNS = Object.freeze([
91
+ /^(?:(?!\n[ \t]*\r?\n)[\s\S]){0,600}?\berror code:\s*relay\.[a-z0-9-]+\.(?=\s|$)/i,
92
+ /^relay recovery limit reached after \d+ attempts/i,
93
+ /^relay timeout after \d+ attempts/i,
94
+ ]);
95
+ const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
96
+ // Assistant rows that are markers, not replies of their own.
97
+ const STUB_KINDS = new Set(['folded', 'stopped']);
98
+
99
+ function toText(value) {
100
+ return String(value ?? '').trim();
101
+ }
102
+
103
+ function isPlainObject(value) {
104
+ return !!value && typeof value === 'object' && !Array.isArray(value);
105
+ }
106
+
107
+ function shortId(value) {
108
+ return toText(value).slice(0, 8) || '-';
109
+ }
110
+
111
+ function nonNegativeInt(value) {
112
+ const number = Number(value);
113
+ return Number.isInteger(number) && number >= 0 ? number : 0;
114
+ }
115
+
116
+ /** Keeps the head and the tail of a long text: the context and the conclusion. */
117
+ function clipMiddle(value, max) {
118
+ const text = String(value ?? '');
119
+ if (text.length <= max) return { text, truncated: false };
120
+ const marker = ` … [${text.length - max} characters trimmed] … `;
121
+ const room = Math.max(0, max - marker.length);
122
+ const head = Math.ceil(room * 0.6);
123
+ return { text: `${text.slice(0, head)}${marker}${text.slice(text.length - (room - head))}`, truncated: true };
124
+ }
125
+
126
+ /** The newest part of a text that is still growing (a live stream). */
127
+ function clipTail(value, max) {
128
+ const text = String(value ?? '');
129
+ return text.length <= max ? text : `…${text.slice(text.length - (max - 1))}`;
130
+ }
131
+
132
+ function clipLine(value, max) {
133
+ const text = toText(value).replace(/\s+/g, ' ');
134
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
135
+ }
136
+
137
+ function firstLine(value, max) {
138
+ const line = String(value ?? '').split(/\r?\n/).map((part) => part.trim()).find(Boolean) || '';
139
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
140
+ }
141
+
142
+ function encodeCursor(cursor) {
143
+ return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url');
144
+ }
145
+
146
+ /** The cursor object the agent passed back, or null when it is not one of ours. */
147
+ function decodeCursor(value, keys) {
148
+ try {
149
+ const parsed = JSON.parse(Buffer.from(toText(value), 'base64url').toString('utf8'));
150
+ if (!isPlainObject(parsed)) return null;
151
+ const cursor = {};
152
+ for (const key of keys) {
153
+ const text = toText(parsed[key]);
154
+ if (text) cursor[key] = text.slice(0, 200);
155
+ }
156
+ return Object.keys(cursor).length ? cursor : null;
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ function compactQuery(query = {}) {
163
+ const out = {};
164
+ for (const [key, value] of Object.entries(query)) {
165
+ if (value === undefined || value === null || value === '') continue;
166
+ out[key] = value;
167
+ }
168
+ return out;
169
+ }
170
+
171
+ function looksLikeFailureText(text) {
172
+ const value = toText(text);
173
+ return FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
174
+ }
175
+
176
+ function normalizeProvider(value) {
177
+ const provider = toText(value).toLowerCase();
178
+ return REMOTE_RELAY_PROVIDERS.includes(provider) ? provider : '';
179
+ }
180
+
181
+ function uniqueTexts(values) {
182
+ const seen = new Set();
183
+ const out = [];
184
+ for (const value of values) {
185
+ const text = toText(value);
186
+ if (!text || seen.has(text.toLowerCase())) continue;
187
+ seen.add(text.toLowerCase());
188
+ out.push(text);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** The providers /api/models lists for a model id (keys may keep their casing). */
194
+ function catalogProvidersFor(providersByModel, model) {
195
+ if (!isPlainObject(providersByModel)) return [];
196
+ const direct = providersByModel[model] || providersByModel[String(model).toLowerCase()];
197
+ const list = Array.isArray(direct)
198
+ ? direct
199
+ : (Object.entries(providersByModel).find(([key]) => key.toLowerCase() === String(model).toLowerCase())?.[1] || []);
200
+ return Array.isArray(list) ? list.map((entry) => toText(entry).toLowerCase()).filter(Boolean) : [];
201
+ }
202
+
203
+ /**
204
+ * The remote's own id for `candidate` among `offered`: a case-insensitive
205
+ * match, or the same Claude model without / with its "[1m]" long-context
206
+ * suffix. '' when the remote does not offer it.
207
+ */
208
+ function matchOfferedModel(candidate, offered = []) {
209
+ const wanted = toText(candidate).toLowerCase();
210
+ if (!wanted) return '';
211
+ const exact = offered.find((model) => model.toLowerCase() === wanted);
212
+ if (exact) return exact;
213
+ const wantedBase = wanted.replace(CLAUDE_LONG_CONTEXT_SUFFIX, '');
214
+ return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
215
+ }
216
+
217
+ function isRemoteRelayError(error) {
218
+ return error?.name === 'RemoteRelayError' || /^REMOTE_/.test(toText(error?.code));
219
+ }
220
+
221
+ /** A failure that says nothing about whether the request got through: no connection, a timeout, a 5xx. */
222
+ function isTransientRemoteError(error) {
223
+ if (!isRemoteRelayError(error)) return false;
224
+ return toText(error.code) === REMOTE_RELAY_ERROR_CODES.offline || Number(error.status) >= 500;
225
+ }
226
+
227
+ // POST /api/message answers a replayed messageId with this (409).
228
+ const DUPLICATE_MESSAGE_ID_CODE = 'DUPLICATE_MESSAGE_ID';
229
+
230
+ function isOwnDuplicateMessage(error, messageId) {
231
+ const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
232
+ if (Number(error?.status) !== 409 || toText(body?.code) !== DUPLICATE_MESSAGE_ID_CODE) return false;
233
+ return !toText(body.messageId) || toText(body.messageId) === messageId;
234
+ }
235
+
236
+ /** A refusal decided on this relay, with the status and code the agent sees. */
237
+ class DispatchFailure extends Error {
238
+ constructor(status, code, message, extra = {}) {
239
+ super(message);
240
+ this.name = 'DispatchFailure';
241
+ this.status = status;
242
+ this.code = code;
243
+ this.extra = extra;
244
+ }
245
+ }
246
+
247
+ function failure(status, code, error, extra = {}) {
248
+ return { status, body: { ok: false, code, error, ...extra } };
249
+ }
250
+
251
+ function invalidInput(error) {
252
+ return new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, error);
253
+ }
254
+
255
+ const PERMISSION_DESCRIPTIONS = Object.freeze({
256
+ read: 'only read',
257
+ prompt: 'only read and send prompts',
258
+ full: 'do everything',
259
+ });
260
+
261
+ function relayTarget(relay) {
262
+ return { id: relay.id, name: relay.name, url: relay.url };
263
+ }
264
+
265
+ /**
266
+ * The question card's view of a relay question: what the agent needs to answer
267
+ * it. Null for the remote's own remote-relay approval cards, which only its
268
+ * user may answer (the remote hides them from us as well).
269
+ */
270
+ function mapPendingQuestion(question) {
271
+ if (!isPlainObject(question) || !toText(question.id)) return null;
272
+ if (isRemoteRelayApprovalQuestion(question)) return null;
273
+ const context = isPlainObject(question.context) ? question.context : {};
274
+ const fields = schemaFields(question.requestSchema);
275
+ return {
276
+ id: toText(question.id),
277
+ question: toText(question.prompt),
278
+ header: toText(context.header) || null,
279
+ options: Array.isArray(question.choices) ? question.choices.map((choice) => toText(choice)).filter(Boolean) : [],
280
+ multiSelect: context.multiSelect === true,
281
+ allowFreeform: question.allowFreeform !== false,
282
+ ...(fields.length ? {
283
+ fields: fields.map((field) => ({
284
+ name: field.name,
285
+ title: field.title,
286
+ type: field.type,
287
+ required: field.required,
288
+ ...(field.choices.length ? { options: field.choices.map((choice) => String(choice.value)) } : {}),
289
+ })),
290
+ } : {}),
291
+ message_id: toText(question.messageId) || null,
292
+ expiresAt: question.expiresAt || null,
293
+ };
294
+ }
295
+
296
+ function activityTexts(activities, { max = ACTIVITY_LINES_PER_MESSAGE, fromEnd = false } = {}) {
297
+ const lines = (Array.isArray(activities) ? activities : [])
298
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.text))
299
+ .map((line) => clipLine(line, ACTIVITY_LINE_MAX))
300
+ .filter(Boolean);
301
+ return fromEnd ? lines.slice(-max) : lines.slice(0, max);
302
+ }
303
+
304
+ /**
305
+ * What a turn in flight is doing: its last main-thread stream text and its
306
+ * last activity lines. `messageId` names the message the caller waits for, so
307
+ * the snapshot can say whether that message's own turn is running.
308
+ */
309
+ function progressSnapshot(inFlight, messageId = '') {
310
+ if (!isPlainObject(inFlight)) return null;
311
+ const events = Array.isArray(inFlight.streamEvents) ? inFlight.streamEvents : [];
312
+ const mainThread = events.filter((event) => !event?.subagentRunId);
313
+ const lastEvent = (mainThread.length ? mainThread : events).at(-1);
314
+ const turnMessageId = toText(inFlight.messageId) || null;
315
+ const own = !messageId || turnMessageId === messageId;
316
+ return {
317
+ status: own ? 'running' : 'waiting_behind_turn',
318
+ turnMessageId,
319
+ startedAt: inFlight.processingAt || inFlight.timestamp || null,
320
+ text: clipTail(toText(lastEvent?.text), PROGRESS_TEXT_MAX),
321
+ activities: activityTexts(inFlight.activities, { max: PROGRESS_ACTIVITY_COUNT, fromEnd: true }),
322
+ };
323
+ }
324
+
325
+ const FINISHED_TASK_STATUSES = new Set(['completed', 'complete', 'done', 'failed', 'stopped', 'killed', 'cancelled', 'canceled']);
326
+
327
+ /**
328
+ * Whether the remote agent keeps working after it answered `replyMessageId`:
329
+ * a Claude agent often starts a background task, ends its turn with an
330
+ * interim reply ("started it in the background"), and answers for real in a
331
+ * follow-up turn (kind `continuation`) once the task reports back. `rows` are
332
+ * the rows after the sent message, oldest first; `page` is the conversation
333
+ * read they came with. A later user message ends the follow-up: from there
334
+ * the session is working for someone else.
335
+ */
336
+ export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundWork = false } = {}) {
337
+ const list = Array.isArray(rows) ? rows : [];
338
+ const start = list.findIndex((row) => toText(row?.id) === replyMessageId);
339
+ const afterReply = start === -1 ? [] : list.slice(start + 1);
340
+ const nextUser = afterReply.findIndex((row) => row?.role === 'user');
341
+ const window = nextUser === -1 ? afterReply : afterReply.slice(0, nextUser);
342
+ const followUps = window.filter((row) => row?.role === 'assistant' && toText(row?.kind).toLowerCase() === 'continuation');
343
+ const tasks = (Array.isArray(page?.backgroundTasks) ? page.backgroundTasks : [])
344
+ .filter((task) => !FINISHED_TASK_STATUSES.has(toText(task?.status).toLowerCase()));
345
+ const handedOver = nextUser !== -1;
346
+ // A queued follow-up turn shows only as activeTurn (between a task's end and
347
+ // the follow-up turn starting); on its own, activeTurn is too weak a sign, so
348
+ // it counts once background work or a follow-up was seen for this message.
349
+ const backgroundSeen = sawBackgroundWork || tasks.length > 0 || followUps.length > 0;
350
+ const working = !handedOver && (
351
+ tasks.length > 0
352
+ || (isPlainObject(page?.inFlight) && backgroundSeen)
353
+ || (page?.activeTurn === true && backgroundSeen)
354
+ );
355
+ return {
356
+ backgroundSeen,
357
+ working,
358
+ latest: followUps.at(-1) || null,
359
+ followUpCount: followUps.length,
360
+ tasks: tasks.slice(0, 5).map((task) => clipLine(task?.description || task?.summary || task?.title || task?.taskId || 'background task', 120)),
361
+ taskCount: tasks.length,
362
+ };
363
+ }
364
+
365
+ function replyOf(row, extra = {}) {
366
+ const clipped = clipMiddle(toText(row?.text), REPLY_TEXT_MAX);
367
+ return {
368
+ text: clipped.text,
369
+ model: toText(row?.model) || null,
370
+ messageId: toText(row?.id) || null,
371
+ ...(clipped.truncated ? { truncated: true } : {}),
372
+ ...extra,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * The outcome for `messageId` from the rows after it (oldest first), or null
378
+ * while there is none yet. `{ foldedWithoutReply:true }` means the message was
379
+ * folded into a turn whose reply has not appeared (yet).
380
+ */
381
+ export function settleRemoteReply(messages, messageId) {
382
+ const rows = Array.isArray(messages) ? messages : [];
383
+ const own = rows.find((row) => row?.role === 'assistant' && toText(row?.sourceMessageId) === messageId);
384
+ if (!own) return null;
385
+ const kind = toText(own.kind).toLowerCase();
386
+ if (kind === 'stopped') {
387
+ return { status: 'stopped', note: 'The remote turn was stopped before it answered this message.' };
388
+ }
389
+ if (kind === 'folded') {
390
+ const answer = rows.find((row) => row?.role === 'assistant'
391
+ && row !== own
392
+ && (!toText(row.kind) || toText(row.kind).toLowerCase() === 'absorbed'));
393
+ if (!answer) return { foldedWithoutReply: true };
394
+ const note = 'The message was steered into the turn that was already running; this is that turn\'s reply.';
395
+ if (looksLikeFailureText(answer.text)) return { status: 'failed', reply: replyOf(answer, { folded: true }), note };
396
+ return { status: 'done', reply: replyOf(answer, { folded: true }), note };
397
+ }
398
+ if (looksLikeFailureText(own.text)) {
399
+ return { status: 'failed', reply: replyOf(own), note: 'The remote turn failed; the reply is the relay\'s failure text.' };
400
+ }
401
+ if (kind === 'absorbed') {
402
+ return {
403
+ status: 'done',
404
+ reply: replyOf(own, { absorbed: true }),
405
+ note: 'The remote agent picked up a later message mid-reply, so the turn continued there; read_session shows the rest.',
406
+ };
407
+ }
408
+ return { status: 'done', reply: replyOf(own) };
409
+ }
410
+
411
+ export function createRemoteRelayDispatcher({
412
+ registry,
413
+ client,
414
+ repository,
415
+ getCallerContext = async () => null,
416
+ requestApproval = async () => false,
417
+ now = Date.now,
418
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
419
+ logger = console,
420
+ emitStatusEvent = () => {},
421
+ pollIntervalMs = 2000,
422
+ randomUUID = nodeRandomUUID,
423
+ // The OAR MCP server names its session by SDK session id, which older
424
+ // conversations do not share with their conversation id: map it back, so
425
+ // the unlock gate and the caller context read the right conversation.
426
+ resolveConversationId = (id) => id,
427
+ } = {}) {
428
+ const inflightByConversation = new Map();
429
+ const callTimesByConversation = new Map();
430
+ const writeTimesByConversation = new Map();
431
+
432
+ // ─── Bookkeeping ───────────────────────────────────────────────────────────
433
+
434
+ function inflight(conversationId) {
435
+ const requested = toText(conversationId);
436
+ let resolved = requested;
437
+ try {
438
+ resolved = toText(resolveConversationId(requested)) || requested;
439
+ } catch {}
440
+ return inflightByConversation.get(resolved) || 0;
441
+ }
442
+
443
+ // The runtime passes no repository when migration 0005 failed this boot:
444
+ // nothing is unlocked then (fail closed).
445
+ function isUnlocked(conversationId, relayId) {
446
+ try {
447
+ return !!repository?.hasUnlock?.(conversationId, relayId);
448
+ } catch {
449
+ return false;
450
+ }
451
+ }
452
+
453
+ function adjustInflight(conversationId, delta) {
454
+ const next = inflight(conversationId) + delta;
455
+ if (next > 0) inflightByConversation.set(conversationId, next);
456
+ else inflightByConversation.delete(conversationId);
457
+ }
458
+
459
+ function recentTimes(map, conversationId, cutoff) {
460
+ const times = (map.get(conversationId) || []).filter((at) => at > cutoff);
461
+ if (times.length) map.set(conversationId, times);
462
+ else map.delete(conversationId);
463
+ return times;
464
+ }
465
+
466
+ /** Sliding one-minute windows per conversation. Null when allowed (and counted). */
467
+ function consumeRateLimit(conversationId, write) {
468
+ const at = now();
469
+ const cutoff = at - RATE_WINDOW_MS;
470
+ const calls = recentTimes(callTimesByConversation, conversationId, cutoff);
471
+ const writes = recentTimes(writeTimesByConversation, conversationId, cutoff);
472
+ const limited = calls.length >= REMOTE_RELAY_LIMITS.callsPerMinute
473
+ ? calls
474
+ : (write && writes.length >= REMOTE_RELAY_LIMITS.writesPerMinute ? writes : null);
475
+ if (limited) {
476
+ return Math.max(1, Math.ceil((limited[0] + RATE_WINDOW_MS - at) / 1000));
477
+ }
478
+ callTimesByConversation.set(conversationId, [...calls, at]);
479
+ if (write) writeTimesByConversation.set(conversationId, [...writes, at]);
480
+ return null;
481
+ }
482
+
483
+ function auditWrite({ conversationId, action, relay, session, outcome, ok }) {
484
+ const relayName = toText(relay?.name) || '-';
485
+ try {
486
+ logger?.log?.(`${LOG_PREFIX} ${action} ${relayName} ${shortId(session)} → ${outcome} conv=${shortId(conversationId)}`);
487
+ } catch {}
488
+ try {
489
+ emitStatusEvent({
490
+ type: 'remote_relay',
491
+ action,
492
+ relay: relayName,
493
+ relayId: toText(relay?.id) || null,
494
+ session: toText(session) || null,
495
+ result: outcome,
496
+ ok: !!ok,
497
+ conversationId,
498
+ });
499
+ } catch {}
500
+ }
501
+
502
+ // ─── Remote calls ──────────────────────────────────────────────────────────
503
+
504
+ function call(ctx, method, path, { query, body, timeoutMs } = {}) {
505
+ return client.request(ctx.relay, method, path, {
506
+ ...(query ? { query: compactQuery(query) } : {}),
507
+ ...(body !== undefined ? { body } : {}),
508
+ ...(timeoutMs ? { timeoutMs } : {}),
509
+ hops: ctx.hops,
510
+ });
511
+ }
512
+
513
+ function sessionPath(session, suffix = '') {
514
+ return `/api/conversation/${encodeURIComponent(session)}${suffix}`;
515
+ }
516
+
517
+ async function loadPendingQuestions(ctx, conversationId) {
518
+ try {
519
+ const response = await call(ctx, 'GET', '/api/relay-questions', {
520
+ query: { status: 'pending', conversationId },
521
+ });
522
+ return (Array.isArray(response?.questions) ? response.questions : [])
523
+ .map(mapPendingQuestion)
524
+ .filter(Boolean);
525
+ } catch {
526
+ return [];
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Whether nothing is queued or running in the remote conversation. Uses the
532
+ * payload's `activeTurn` when the remote sends one; otherwise (it only has
533
+ * `inFlight`, which misses a row still waiting for its worker) the
534
+ * relay-wide queue counts, which are conservative: any queued work anywhere
535
+ * reads as "not idle".
536
+ */
537
+ async function remoteConversationIdle(ctx, page) {
538
+ if (page?.inFlight) return false;
539
+ if (page?.activeTurn === true) return false;
540
+ if (page?.activeTurn === false) return true;
541
+ try {
542
+ const status = await call(ctx, 'GET', '/api/status');
543
+ const queued = nonNegativeInt(status?.pendingCount)
544
+ + nonNegativeInt(status?.processingCount)
545
+ + nonNegativeInt(status?.parkedCount);
546
+ return queued === 0;
547
+ } catch {
548
+ return false;
549
+ }
550
+ }
551
+
552
+ async function buildOrigin(ctx) {
553
+ const self = (await registry.selfIdentity?.()) || {};
554
+ return normalizeRemoteRelayOrigin({
555
+ relayId: self.relayId,
556
+ relayName: self.name || (await registry.selfName?.()),
557
+ relayUrl: self.publicUrl || '',
558
+ conversationId: ctx.caller.conversationId || ctx.conversationId,
559
+ conversationTitle: ctx.caller.title,
560
+ provider: ctx.caller.provider,
561
+ model: ctx.caller.model,
562
+ hops: ctx.hops,
563
+ });
564
+ }
565
+
566
+ /**
567
+ * The rows after the sent message: `page` (read with `limit`), and while
568
+ * pages come back full, the ones after it, up to `maxPages` pages in all.
569
+ * `truncated` when there were more than that.
570
+ */
571
+ async function rowsAfterMessage(ctx, session, page, limit, maxPages) {
572
+ const rows = Array.isArray(page?.messages) ? [...page.messages] : [];
573
+ let current = page;
574
+ let currentLimit = limit;
575
+ for (let pages = 1; ; pages += 1) {
576
+ const got = Array.isArray(current?.messages) ? current.messages : [];
577
+ const hasMoreNewer = current?.pageInfo?.hasMoreNewer;
578
+ const more = typeof hasMoreNewer === 'boolean' ? hasMoreNewer : got.length >= currentLimit;
579
+ const cursor = toText(got.at(-1)?.id);
580
+ if (!more || !cursor) return { rows, truncated: false };
581
+ if (pages >= maxPages) return { rows, truncated: true };
582
+ currentLimit = HISTORY_PAGE_MAX;
583
+ current = await call(ctx, 'GET', sessionPath(session), {
584
+ query: { afterMessageId: cursor, limit: currentLimit },
585
+ });
586
+ rows.push(...(Array.isArray(current?.messages) ? current.messages : []));
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Polls the remote transcript until `messageId` is answered, settles
592
+ * otherwise, or `waitSeconds` run out. One read per poll in the common case.
593
+ * The prompt is queued by then, so a failed read never surfaces as a bare
594
+ * error (which reads as "not sent" and invites a second send or a second
595
+ * session): the result stays `queued` / `running` with a note to wait again.
596
+ */
597
+ async function waitForReply(ctx, { session, messageId, waitSeconds }) {
598
+ const seen = { inFlight: null };
599
+ try {
600
+ return await pollForReply(ctx, { session, messageId, waitSeconds, seen });
601
+ } catch (error) {
602
+ const remote = isRemoteRelayError(error);
603
+ const code = remote ? (toText(error.code) || REMOTE_RELAY_ERROR_CODES.offline) : REMOTE_RELAY_INTERNAL_ERROR_CODE;
604
+ if (!remote) {
605
+ try {
606
+ logger?.warn?.(`${LOG_PREFIX} wait failed conv=${shortId(ctx.conversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
607
+ } catch {}
608
+ }
609
+ return {
610
+ session,
611
+ message_id: messageId,
612
+ status: seen.inFlight ? 'running' : 'queued',
613
+ progress: progressSnapshot(seen.inFlight, messageId) || { status: 'queued', text: '', activities: [] },
614
+ note: `The wait failed (${code}): the prompt was queued; call wait with this session and message_id to keep waiting. Do not send it again.`,
615
+ };
616
+ }
617
+ }
618
+
619
+ async function pollForReply(ctx, { session, messageId, waitSeconds, seen }) {
620
+ const deadline = now() + Math.max(0, waitSeconds) * 1000;
621
+ const base = { session, message_id: messageId };
622
+ let idleSeen = false;
623
+ let firstRead = true;
624
+ for (;;) {
625
+ // The confirming read before "cancelled" looks as far as the remote
626
+ // allows per page, for a reply that sits unusually far behind its
627
+ // message. It and the first read (a late wait) follow a full page with
628
+ // the pages after it; the polls in between watch the first page only.
629
+ const limit = idleSeen ? HISTORY_PAGE_MAX : WAIT_PAGE_LIMIT;
630
+ const page = await call(ctx, 'GET', sessionPath(session), {
631
+ query: { afterMessageId: messageId, limit },
632
+ });
633
+ const inFlight = isPlainObject(page?.inFlight) ? page.inFlight : null;
634
+ seen.inFlight = inFlight;
635
+ const after = await rowsAfterMessage(ctx, session, page, limit, firstRead || idleSeen ? AFTER_SCAN_PAGES : 1);
636
+ firstRead = false;
637
+ const conversationId = toText(page?.id) || session;
638
+ const settled = settleRemoteReply(after.rows, messageId);
639
+ if (settled && !settled.foldedWithoutReply) {
640
+ if (settled.status !== 'done' || !settled.reply?.messageId) return { ...base, ...settled };
641
+ const follow = remoteFollowUpState(after.rows, settled.reply.messageId, page, { sawBackgroundWork: seen.background === true });
642
+ if (follow.backgroundSeen) seen.background = true;
643
+ const latestReply = follow.latest ? replyOf(follow.latest, { followUp: true }) : settled.reply;
644
+ if (!follow.working) {
645
+ if (!follow.backgroundSeen) return { ...base, ...settled };
646
+ // After background work, a quiet moment is not the end yet: the
647
+ // worker publishes a new task set up to 2 s late, and a finished
648
+ // task opens its follow-up turn a moment after. Done only once the
649
+ // session stays quiet for the whole settle window.
650
+ if (!seen.quietSince) seen.quietSince = now();
651
+ if (now() - seen.quietSince >= FOLLOW_UP_SETTLE_MS) {
652
+ return {
653
+ ...base,
654
+ status: 'done',
655
+ reply: latestReply,
656
+ ...(follow.latest ? { firstReply: settled.reply } : {}),
657
+ note: follow.latest
658
+ ? `The remote agent finished work it had continued in the background; this is its last follow-up reply (${follow.followUpCount} in all, read_session shows every one).`
659
+ : 'The remote agent\'s background work ended without a follow-up reply; the reply above is its only one.',
660
+ };
661
+ }
662
+ } else {
663
+ seen.quietSince = null;
664
+ }
665
+ if (inFlight) {
666
+ const pendingQuestions = await loadPendingQuestions(ctx, toText(page?.id) || session);
667
+ if (pendingQuestions.length) {
668
+ return {
669
+ ...base,
670
+ status: 'waiting_for_answer',
671
+ reply: latestReply,
672
+ pendingQuestions,
673
+ progress: progressSnapshot(inFlight),
674
+ note: 'The remote agent answered, kept working in the background and now waits for an answer. Ask the user (unless they told you to decide), then call answer_question and wait again.',
675
+ };
676
+ }
677
+ }
678
+ const remainingForFollowUp = deadline - now();
679
+ if (remainingForFollowUp <= 0 || ctx.signal?.aborted) {
680
+ return {
681
+ ...base,
682
+ status: 'running',
683
+ reply: latestReply,
684
+ ...(follow.latest ? { firstReply: settled.reply } : {}),
685
+ background: { tasks: follow.taskCount, running: follow.tasks },
686
+ progress: progressSnapshot(inFlight) || { status: 'background', text: '', activities: [] },
687
+ note: 'The remote agent answered but keeps working in the background; the reply above is its latest so far. Call wait with this session and message_id for the follow-up.',
688
+ };
689
+ }
690
+ await sleep(Math.min(pollIntervalMs, remainingForFollowUp));
691
+ continue;
692
+ }
693
+
694
+ if (inFlight) {
695
+ idleSeen = false;
696
+ const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
697
+ if (pendingQuestions.length) {
698
+ return {
699
+ ...base,
700
+ status: 'waiting_for_answer',
701
+ pendingQuestions,
702
+ progress: progressSnapshot(inFlight, messageId),
703
+ note: 'The remote agent is waiting for an answer. Ask the user (unless they told you to decide), then call answer_question and wait again.',
704
+ };
705
+ }
706
+ } else if (await remoteConversationIdle(ctx, page)) {
707
+ if (settled?.foldedWithoutReply) {
708
+ return {
709
+ ...base,
710
+ status: 'done',
711
+ note: 'The message was steered into a turn that has finished, but its reply was not found after the message; use read_session to see it.',
712
+ };
713
+ }
714
+ // A reply and the end of its turn are committed together, but the two
715
+ // reads above are not one snapshot: look once more before calling the
716
+ // message cancelled.
717
+ if (idleSeen && after.truncated) {
718
+ return {
719
+ ...base,
720
+ status: 'done',
721
+ note: `Nothing is queued or running in the remote session, but no reply to this message was found in the ${after.rows.length} messages after it; use read_session to look further back.`,
722
+ };
723
+ }
724
+ if (idleSeen) {
725
+ return {
726
+ ...base,
727
+ status: 'cancelled',
728
+ note: 'Nothing is queued or running in the remote session and the message got no reply (it was cancelled or removed there).',
729
+ };
730
+ }
731
+ idleSeen = true;
732
+ continue;
733
+ } else {
734
+ idleSeen = false;
735
+ }
736
+
737
+ const remaining = deadline - now();
738
+ if (remaining <= 0 || ctx.signal?.aborted) {
739
+ return {
740
+ ...base,
741
+ status: inFlight ? 'running' : 'queued',
742
+ progress: progressSnapshot(inFlight, messageId) || { status: 'queued', text: '', activities: [] },
743
+ note: 'Not finished yet. Call wait with this session and message_id to keep waiting.',
744
+ };
745
+ }
746
+ await sleep(Math.min(pollIntervalMs, remaining));
747
+ }
748
+ }
749
+
750
+ // ─── Remote catalogue (relay_info, create_session) ─────────────────────────
751
+
752
+ async function loadCatalog(ctx) {
753
+ const [status, models, copilot, ...settings] = await Promise.allSettled([
754
+ call(ctx, 'GET', '/api/status'),
755
+ call(ctx, 'GET', '/api/models'),
756
+ call(ctx, 'GET', '/api/settings/copilot'),
757
+ ...SETTINGS_PROVIDERS.map((provider) => call(ctx, 'GET', `/api/settings/${provider}`)),
758
+ ]);
759
+ if (status.status === 'rejected') throw status.reason;
760
+ const value = (result) => (result.status === 'fulfilled' && isPlainObject(result.value) ? result.value : null);
761
+ return {
762
+ status: value(status) || {},
763
+ models: value(models),
764
+ copilot: value(copilot),
765
+ settings: Object.fromEntries(SETTINGS_PROVIDERS.map((provider, index) => [provider, value(settings[index])])),
766
+ };
767
+ }
768
+
769
+ /** `[{ provider, configured, defaultModel, models }]` in contract order. */
770
+ function buildProviders(catalog) {
771
+ const models = catalog.models || {};
772
+ const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
773
+ const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
774
+ const modelsLabelled = (label) => Object.entries(providersByModel)
775
+ .filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
776
+ .map(([model]) => model);
777
+
778
+ return REMOTE_RELAY_PROVIDERS.map((provider) => {
779
+ if (provider === 'github') {
780
+ // Copilot's catalogue is every model no other provider claims alone.
781
+ const githubModels = catalogModels.filter((model) => {
782
+ const providers = catalogProvidersFor(providersByModel, model);
783
+ return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
784
+ });
785
+ const defaultModel = toText(catalog.status.defaultModel) || toText(models.defaultModel) || toText(models.currentModel) || null;
786
+ return {
787
+ provider,
788
+ configured: true,
789
+ defaultModel,
790
+ models: uniqueTexts([...(defaultModel ? [defaultModel] : []), ...githubModels]),
791
+ ...(toText(catalog.copilot?.engine) ? { engine: toText(catalog.copilot.engine) } : {}),
792
+ };
793
+ }
794
+ const settings = catalog.settings[provider];
795
+ if (!settings) return { provider, configured: false, defaultModel: null, models: [] };
796
+ // What POST /api/conversation/bootstrap requires, plus "not switched off".
797
+ const configured = settings.enabled === true
798
+ && (provider === 'claude' || provider === 'grok' || settings.configured === true);
799
+ const defaultModel = toText(settings.model) || null;
800
+ const listed = provider === 'openai'
801
+ ? modelsLabelled(CATALOG_PROVIDER_LABELS.openai)
802
+ : (Array.isArray(settings.models) ? settings.models : []);
803
+ return {
804
+ provider,
805
+ configured,
806
+ defaultModel,
807
+ models: configured ? uniqueTexts([...(defaultModel ? [defaultModel] : []), ...listed]) : [],
808
+ };
809
+ });
810
+ }
811
+
812
+ // ─── Actions ───────────────────────────────────────────────────────────────
813
+
814
+ async function relayInfo(ctx) {
815
+ const [identity, catalog] = await Promise.all([
816
+ call(ctx, 'GET', '/api/relay/identity').catch(() => null),
817
+ loadCatalog(ctx),
818
+ ]);
819
+ const status = catalog.status;
820
+ const providers = buildProviders(catalog);
821
+ const remoteRelays = isPlainObject(identity?.remoteRelays) ? identity.remoteRelays : null;
822
+ const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
823
+ const version = toText(status.version) || toText(identity?.version) || null;
824
+ return {
825
+ summary: `relay_info → ${ctx.relay.name}: OAR ${version || '?'}, providers ${configured.join(', ') || 'none'}`,
826
+ name: toText(identity?.name) || ctx.relay.name,
827
+ url: ctx.relay.url,
828
+ openUrl: remoteConversationUrl(ctx.relay.url, ''),
829
+ version,
830
+ platform: toText(status.platform) || toText(identity?.platform) || null,
831
+ protocol: remoteRelays ? nonNegativeInt(remoteRelays.protocol) : 0,
832
+ acceptsAgentPrompts: remoteRelays ? remoteRelays.inbound !== false : null,
833
+ providers,
834
+ workspaces: {
835
+ default: toText(status.defaultSessionWorkspaceRootPath) || toText(status.workspaceRootPath) || null,
836
+ current: toText(status.workspaceRootPath) || null,
837
+ recent: Array.isArray(status.recentWorkspaceRoots) ? status.recentWorkspaceRoots.map(toText).filter(Boolean) : [],
838
+ },
839
+ relayModes: Array.isArray(status.supportedRelayModes) ? status.supportedRelayModes : [],
840
+ defaultRelayMode: toText(status.defaultRelayMode) || null,
841
+ };
842
+ }
843
+
844
+ async function liveWorkerConversationIds(ctx) {
845
+ try {
846
+ const status = await call(ctx, 'GET', '/api/status');
847
+ const workers = Array.isArray(status?.sessionWorker?.workers) ? status.sessionWorker.workers : [];
848
+ return new Set(workers
849
+ .filter((worker) => WORKING_WORKER_STATUSES.has(toText(worker?.status).toLowerCase()))
850
+ .map((worker) => toText(worker?.conversationId))
851
+ .filter(Boolean));
852
+ } catch {
853
+ return new Set();
854
+ }
855
+ }
856
+
857
+ function mapSession(row, workerIds) {
858
+ const id = toText(row?.id);
859
+ const via = toText(row?.origin?.relayName);
860
+ return {
861
+ id,
862
+ title: toText(row?.title),
863
+ provider: toText(row?.runtimeProviderType) || 'github',
864
+ model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
865
+ active: row?.activeTurn === true || !!workerIds?.has(id),
866
+ updatedAt: row?.updatedAt || null,
867
+ messageCount: nonNegativeInt(row?.messageCount),
868
+ cwd: toText(row?.currentWorkspaceRootPath) || null,
869
+ ...(via ? { via } : {}),
870
+ };
871
+ }
872
+
873
+ async function listSessions(ctx, args) {
874
+ const { scope, limit } = args;
875
+ const query = toText(args.query).toLowerCase();
876
+ // recent = the newest page, active = working sessions among the newest
877
+ // pages, all = the whole list page by page (only it honours the cursor).
878
+ let cursor = null;
879
+ if (scope === 'all' && args.cursor) {
880
+ cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt']);
881
+ if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
882
+ }
883
+ const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
884
+ const filtering = !!query || scope === 'active';
885
+ const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
886
+ && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)));
887
+
888
+ const sessions = [];
889
+ let hasMore = false;
890
+ let scanned = 0;
891
+ for (let page = 0; page < (filtering ? LIST_SCAN_PAGES : 1); page += 1) {
892
+ const response = await call(ctx, 'GET', '/api/conversations', {
893
+ query: {
894
+ limit: filtering ? LIST_PAGE_MAX : limit,
895
+ beforeConversationId: cursor?.beforeConversationId,
896
+ beforeUpdatedAt: cursor?.beforeUpdatedAt,
897
+ },
898
+ });
899
+ const rows = Array.isArray(response?.conversations) ? response.conversations : [];
900
+ let consumed = 0;
901
+ for (const row of rows) {
902
+ consumed += 1;
903
+ if (keep(row)) sessions.push(mapSession(row, workerIds));
904
+ if (sessions.length >= limit) break;
905
+ }
906
+ scanned += consumed;
907
+ const last = rows[consumed - 1];
908
+ if (last) cursor = { beforeConversationId: toText(last.id), beforeUpdatedAt: toText(last.updatedAt) };
909
+ hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
910
+ if (sessions.length >= limit || !hasMore || !rows.length) break;
911
+ }
912
+
913
+ const nextCursor = scope === 'all' && hasMore && cursor ? encodeCursor(cursor) : null;
914
+ const note = filtering && hasMore && sessions.length < limit && scope !== 'all'
915
+ ? `Searched the newest ${scanned} sessions only; use scope "all" with the cursor to search further.`
916
+ : null;
917
+ return {
918
+ summary: `list_sessions → ${ctx.relay.name}: ${sessions.length} session${sessions.length === 1 ? '' : 's'} (${scope}${query ? `, "${clipLine(args.query, 40)}"` : ''})`,
919
+ scope,
920
+ sessions,
921
+ nextCursor,
922
+ ...(note ? { note } : {}),
923
+ };
924
+ }
925
+
926
+ function messageText(row) {
927
+ const text = String(row?.text ?? '');
928
+ // The header line is ours (or another relay's): the badge fields say the
929
+ // same, so the agent reads the prompt itself.
930
+ return row?.role === 'user' && row?.origin ? stripRemotePromptHeader(text) : text;
931
+ }
932
+
933
+ /**
934
+ * Newest first within `maxChars`. Each message is capped at its share of
935
+ * the budget, but never below perMessageChars: `last:1, max_chars:20000`
936
+ * returns a 9000-character reply whole, ten messages in the default 12000
937
+ * still get 4000 each. A trimmed message keeps its head and its tail.
938
+ */
939
+ function shapeMessages(rows, { maxChars, includeActivity }) {
940
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
941
+ const out = [];
942
+ const perMessageMax = Math.max(REMOTE_RELAY_LIMITS.perMessageChars, Math.floor(maxChars / Math.max(1, list.length)));
943
+ let budget = maxChars;
944
+ let omitted = 0;
945
+ for (let index = list.length - 1; index >= 0; index -= 1) {
946
+ const row = list[index];
947
+ const perMessage = clipMiddle(messageText(row), perMessageMax);
948
+ let text = perMessage.text;
949
+ let truncated = perMessage.truncated;
950
+ if (text.length > budget) {
951
+ if (budget < MIN_TRIMMED_MESSAGE_CHARS && out.length) {
952
+ omitted = index + 1;
953
+ break;
954
+ }
955
+ text = clipMiddle(text, Math.max(budget, MIN_TRIMMED_MESSAGE_CHARS)).text;
956
+ truncated = true;
957
+ }
958
+ budget -= text.length;
959
+ const role = toText(row.role) || 'assistant';
960
+ const via = toText(row?.origin?.relayName);
961
+ const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities) : [];
962
+ out.unshift({
963
+ id: toText(row.id),
964
+ role,
965
+ time: row.timestamp || null,
966
+ ...(toText(row.kind) ? { kind: toText(row.kind) } : {}),
967
+ ...(role === 'assistant' && toText(row.model) ? { model: toText(row.model) } : {}),
968
+ ...(role === 'assistant' && toText(row.sourceMessageId) ? { replyTo: toText(row.sourceMessageId) } : {}),
969
+ ...(via ? { via } : {}),
970
+ text,
971
+ ...(truncated ? { truncated: true } : {}),
972
+ ...(activities.length ? { activities } : {}),
973
+ });
974
+ if (budget <= 0 && index > 0) {
975
+ omitted = index;
976
+ break;
977
+ }
978
+ }
979
+ return { messages: out, omitted };
980
+ }
981
+
982
+ async function readSession(ctx, args) {
983
+ let before = null;
984
+ if (args.before) {
985
+ before = decodeCursor(args.before, ['beforeMessageId', 'beforeTimestamp']);
986
+ if (!before) throw invalidInput('before is not an olderCursor from read_session');
987
+ }
988
+ const conversation = await call(ctx, 'GET', sessionPath(args.session), {
989
+ query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp },
990
+ });
991
+ const conversationId = toText(conversation?.id) || args.session;
992
+ const pendingQuestions = await loadPendingQuestions(ctx, conversationId);
993
+ const { messages, omitted } = shapeMessages(conversation?.messages, {
994
+ maxChars: args.max_chars,
995
+ includeActivity: args.include_activity,
996
+ });
997
+ let olderCursor = null;
998
+ if (omitted > 0 && messages.length) {
999
+ olderCursor = encodeCursor({ beforeMessageId: messages[0].id, beforeTimestamp: toText(messages[0].time) });
1000
+ } else if (conversation?.pageInfo?.hasMoreOlder && isPlainObject(conversation.pageInfo.olderCursor)) {
1001
+ olderCursor = encodeCursor({
1002
+ beforeMessageId: toText(conversation.pageInfo.olderCursor.beforeMessageId),
1003
+ beforeTimestamp: toText(conversation.pageInfo.olderCursor.beforeTimestamp),
1004
+ });
1005
+ }
1006
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1007
+ const via = toText(conversation?.origin?.relayName);
1008
+ const inFlight = progressSnapshot(conversation?.inFlight);
1009
+ return {
1010
+ 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` : ''}`,
1011
+ session: {
1012
+ id: conversationId,
1013
+ title: toText(conversation?.title),
1014
+ provider: toText(runtime.providerType) || 'github',
1015
+ model: toText(runtime.providerModel) || toText(conversation?.preferredModel) || toText(runtime.model) || null,
1016
+ mode: toText(conversation?.preferredRelayMode) || null,
1017
+ cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1018
+ archived: conversation?.archived === true,
1019
+ ...(via ? { via } : {}),
1020
+ openUrl: remoteConversationUrl(ctx.relay.url, conversationId),
1021
+ },
1022
+ messages,
1023
+ ...(inFlight ? { inFlight } : {}),
1024
+ pendingQuestions,
1025
+ ...(olderCursor ? { olderCursor } : {}),
1026
+ ...(omitted > 0 ? { note: `${omitted} older message${omitted === 1 ? '' : 's'} left out to stay within max_chars; pass olderCursor as before to read them.` } : {}),
1027
+ };
1028
+ }
1029
+
1030
+ function outcomeSummary(action, ctx, outcome) {
1031
+ const questions = Array.isArray(outcome.pendingQuestions) ? outcome.pendingQuestions.length : 0;
1032
+ return `${action} → ${ctx.relay.name} session ${shortId(outcome.session)}: ${outcome.status}`
1033
+ + (questions ? `, ${questions} question${questions === 1 ? '' : 's'} pending` : '');
1034
+ }
1035
+
1036
+ async function waitAction(ctx, args) {
1037
+ // An unknown message id would read as "no reply, nothing running" forever.
1038
+ const around = await call(ctx, 'GET', sessionPath(args.session), {
1039
+ query: { aroundMessageId: args.message_id, limit: 1 },
1040
+ });
1041
+ const found = (Array.isArray(around?.messages) ? around.messages : []).some((row) => toText(row?.id) === args.message_id);
1042
+ if (!found) {
1043
+ throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
1044
+ `Message ${args.message_id} is not in session ${args.session} on relay "${ctx.relay.name}".`);
1045
+ }
1046
+ const session = toText(around?.id) || args.session;
1047
+ const outcome = await waitForReply(ctx, { session, messageId: args.message_id, waitSeconds: args.wait_seconds });
1048
+ return { summary: outcomeSummary('wait', ctx, outcome), ...outcome };
1049
+ }
1050
+
1051
+ /**
1052
+ * Queues the prompt. A send whose connection fails may still have landed,
1053
+ * and the remote dedups on messageId, so it is replayed once with the same
1054
+ * id: a 409 DUPLICATE_MESSAGE_ID then means the first attempt got through.
1055
+ * When the replay fails too, nobody knows; the error carries the session
1056
+ * and message id so the agent checks with wait instead of sending again.
1057
+ */
1058
+ async function postPrompt(ctx, { session, text, origin, model, mode }) {
1059
+ const messageId = String(randomUUID());
1060
+ const body = {
1061
+ conversationId: session,
1062
+ text: withRemotePromptHeader(text, origin),
1063
+ messageId,
1064
+ origin,
1065
+ ...(model ? { model } : {}),
1066
+ ...(mode ? { relayMode: mode } : {}),
1067
+ };
1068
+ let sent;
1069
+ try {
1070
+ sent = await call(ctx, 'POST', '/api/message', { body });
1071
+ } catch (error) {
1072
+ if (!isTransientRemoteError(error)) throw error;
1073
+ try {
1074
+ sent = await call(ctx, 'POST', '/api/message', { body });
1075
+ } catch (retryError) {
1076
+ if (isOwnDuplicateMessage(retryError, messageId)) return { duplicate: false, messageId };
1077
+ if (!isTransientRemoteError(retryError)) throw retryError;
1078
+ throw new DispatchFailure(502, toText(retryError.code) || REMOTE_RELAY_ERROR_CODES.offline,
1079
+ `${toText(retryError.message) || `Relay "${ctx.relay.name}" did not answer`}; the prompt may or may not have been queued.`,
1080
+ {
1081
+ session,
1082
+ message_id: messageId,
1083
+ note: 'Call wait with this session and message_id to find out before sending it again: it answers not found when the prompt never arrived.',
1084
+ });
1085
+ }
1086
+ }
1087
+ if (sent?.duplicate === true) {
1088
+ return {
1089
+ duplicate: true,
1090
+ messageId: toText(sent.duplicateOfMessageId) || null,
1091
+ };
1092
+ }
1093
+ return { duplicate: false, messageId: toText(sent?.messageId) || messageId };
1094
+ }
1095
+
1096
+ async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra = {} }) {
1097
+ const openUrl = remoteConversationUrl(ctx.relay.url, session);
1098
+ if (sent.duplicate) {
1099
+ const outcome = {
1100
+ status: 'duplicate',
1101
+ session,
1102
+ message_id: sent.messageId,
1103
+ note: 'The remote relay took this as a repeat of an identical message sent moments ago and did not queue it again. Use wait with this message_id to follow the original.',
1104
+ };
1105
+ return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
1106
+ }
1107
+ if (waitSeconds <= 0) {
1108
+ const outcome = { status: 'queued', session, message_id: sent.messageId };
1109
+ return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
1110
+ }
1111
+ const outcome = await waitForReply(ctx, { session, messageId: sent.messageId, waitSeconds });
1112
+ return { summary: outcomeSummary(action, ctx, outcome), ...outcome, ...extra, openUrl };
1113
+ }
1114
+
1115
+ async function send(ctx, args) {
1116
+ const conversation = await call(ctx, 'GET', sessionPath(args.session), { query: { limit: 1 } });
1117
+ const session = toText(conversation?.id) || args.session;
1118
+ if (args.if_busy === 'fail' && (conversation?.inFlight || conversation?.activeTurn === true)) {
1119
+ throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.busy,
1120
+ `Session ${session} on relay "${ctx.relay.name}" is busy with a turn; not sent (if_busy "fail").`,
1121
+ { session, progress: progressSnapshot(conversation?.inFlight) });
1122
+ }
1123
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1124
+ const provider = toText(runtime.providerType).toLowerCase() || 'github';
1125
+ // Without a model the remote falls back to its catalogue's current one and
1126
+ // silently switches the session; keep the model the session already uses.
1127
+ // Grok pins its model at bootstrap and refuses any other, so it gets none.
1128
+ const model = args.model
1129
+ || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
1130
+ const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
1131
+ const origin = await buildOrigin(ctx);
1132
+ const sent = await postPrompt(ctx, { session, text: args.text, origin, model, mode });
1133
+ return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds });
1134
+ }
1135
+
1136
+ async function createSession(ctx, args) {
1137
+ const catalog = await loadCatalog(ctx);
1138
+ const providers = buildProviders(catalog);
1139
+ const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
1140
+ const provider = args.provider || normalizeProvider(ctx.caller.provider) || 'github';
1141
+ const entry = providers.find((candidate) => candidate.provider === provider);
1142
+ if (!entry?.configured) {
1143
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
1144
+ `Relay "${ctx.relay.name}" has no ${provider} provider set up. Providers there: ${configured.join(', ') || 'none'}. Pass provider to pick one.`,
1145
+ { providers: configured });
1146
+ }
1147
+
1148
+ let model = args.model || '';
1149
+ let modelSource = model ? 'requested' : '';
1150
+ let note = null;
1151
+ if (!model) {
1152
+ model = matchOfferedModel(ctx.caller.model, entry.models);
1153
+ modelSource = model ? 'same as this session' : '';
1154
+ }
1155
+ if (!model) {
1156
+ model = entry.defaultModel || entry.models[0] || '';
1157
+ modelSource = 'remote default';
1158
+ if (toText(ctx.caller.model)) {
1159
+ note = `${provider} on relay "${ctx.relay.name}" does not offer ${ctx.caller.model}; the session uses its default model ${model || '(none)'}.`;
1160
+ }
1161
+ }
1162
+ const mode = args.mode || ctx.caller.mode || toText(catalog.status.defaultRelayMode) || '';
1163
+ const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
1164
+ const origin = await buildOrigin(ctx);
1165
+
1166
+ const bootstrap = await call(ctx, 'POST', '/api/conversation/bootstrap', {
1167
+ body: {
1168
+ providerType: provider,
1169
+ ...(model ? { model } : {}),
1170
+ ...(mode ? { relayMode: mode } : {}),
1171
+ title,
1172
+ ...(args.cwd ? { cwd: args.cwd } : {}),
1173
+ origin,
1174
+ },
1175
+ });
1176
+ const session = toText(bootstrap?.conversationId);
1177
+ if (!session) {
1178
+ throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
1179
+ `Relay "${ctx.relay.name}" did not return the new session's id.`);
1180
+ }
1181
+ const boundModel = toText(bootstrap?.selectedModel) || model;
1182
+ const sent = await postPrompt(ctx, { session, text: args.text, origin, model: boundModel, mode });
1183
+ return deliverAndWait('create_session', ctx, {
1184
+ session,
1185
+ sent,
1186
+ waitSeconds: args.wait_seconds,
1187
+ extra: {
1188
+ provider: toText(bootstrap?.selectedProviderType) || provider,
1189
+ model: boundModel || null,
1190
+ modelSource,
1191
+ mode: toText(bootstrap?.preferredRelayMode) || mode || null,
1192
+ title,
1193
+ ...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
1194
+ ...(note ? { modelNote: note } : {}),
1195
+ },
1196
+ });
1197
+ }
1198
+
1199
+ /** The answer body the remote's answer route expects for this question. */
1200
+ function buildAnswer(question, args) {
1201
+ const fields = schemaFields(question.requestSchema);
1202
+ if (fields.length > 1) {
1203
+ let structured = null;
1204
+ try {
1205
+ structured = JSON.parse(toText(args.answer));
1206
+ } catch {}
1207
+ if (!isPlainObject(structured)) {
1208
+ throw invalidInput(`This question has several fields (${fields.map((field) => field.name).join(', ')}); pass answer as a JSON object with those keys.`);
1209
+ }
1210
+ return { structuredAnswer: structured, text: JSON.stringify(structured) };
1211
+ }
1212
+ const options = Array.isArray(question.choices) ? question.choices.map(toText).filter(Boolean) : [];
1213
+ const labels = [];
1214
+ for (const choice of args.choices || []) {
1215
+ const match = options.find((option) => option.toLowerCase() === choice.toLowerCase()) || '';
1216
+ if (!match && question.allowFreeform === false) {
1217
+ throw invalidInput(`"${clipLine(choice, 80)}" is not an option of this question. Options: ${options.join(', ')}.`);
1218
+ }
1219
+ const label = match || choice;
1220
+ if (!labels.includes(label)) labels.push(label);
1221
+ }
1222
+ // The card's own format: the picked labels, then any typed text, ", "-joined.
1223
+ const extra = toText(args.answer);
1224
+ if (extra && !labels.includes(extra)) labels.push(extra);
1225
+ const text = labels.join(', ');
1226
+ return { answer: text, text };
1227
+ }
1228
+
1229
+ async function answerQuestion(ctx, args) {
1230
+ const fetched = await call(ctx, 'GET', `/api/relay-question/${encodeURIComponent(args.question_id)}`);
1231
+ const question = isPlainObject(fetched?.question) ? fetched.question : null;
1232
+ if (!question) {
1233
+ throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
1234
+ `Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
1235
+ }
1236
+ const session = toText(question.conversationId) || null;
1237
+ if (isRemoteRelayApprovalQuestion(question)) {
1238
+ throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
1239
+ `Question ${args.question_id} is relay "${ctx.relay.name}"'s approval of its own agent's call to another relay; only its user can answer it.`,
1240
+ { session });
1241
+ }
1242
+ if (toText(question.status) && toText(question.status) !== 'pending') {
1243
+ throw new DispatchFailure(409, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}409`,
1244
+ `Question ${args.question_id} on relay "${ctx.relay.name}" is already ${toText(question.status).replace(/_/g, ' ')}.`,
1245
+ { session });
1246
+ }
1247
+ const answer = buildAnswer(question, args);
1248
+ // The answer route checks the session the question belongs to; the card
1249
+ // sends the stored one back, and so do we.
1250
+ const sdkSessionId = toText(question.sdkSessionId);
1251
+ await call(ctx, 'POST', `/api/relay-question/${encodeURIComponent(args.question_id)}/answer`, {
1252
+ body: {
1253
+ ...(answer.structuredAnswer ? { structuredAnswer: answer.structuredAnswer } : { answer: answer.answer }),
1254
+ ...(sdkSessionId ? { sdk_session_id: sdkSessionId } : {}),
1255
+ },
1256
+ });
1257
+ const messageId = toText(question.messageId) || null;
1258
+ return {
1259
+ summary: `answer_question → ${ctx.relay.name} question ${shortId(args.question_id)}: answered`,
1260
+ status: 'answered',
1261
+ question_id: args.question_id,
1262
+ session,
1263
+ message_id: messageId,
1264
+ answer: clipLine(answer.text, 500),
1265
+ note: messageId ? 'Call wait with this session and message_id to follow the turn.' : null,
1266
+ };
1267
+ }
1268
+
1269
+ async function stopTurn(ctx, args) {
1270
+ const response = await call(ctx, 'POST', sessionPath(args.session, '/cancel-turn'), { body: {} });
1271
+ const acknowledgement = toText(response?.acknowledgement) || (response?.queued ? 'stop-queued' : 'unknown');
1272
+ const stopRequested = response?.queued === true;
1273
+ const notes = {
1274
+ 'no-active-turn': 'Nothing was running in that session.',
1275
+ 'active-turn-unbound': 'A turn is running but it is not bound to a worker the relay can stop.',
1276
+ 'already-requested': 'A stop was already requested for the running turn.',
1277
+ };
1278
+ return {
1279
+ summary: `stop → ${ctx.relay.name} session ${shortId(args.session)}: ${stopRequested ? 'stop requested' : acknowledgement}`,
1280
+ session: args.session,
1281
+ stopRequested,
1282
+ acknowledgement,
1283
+ message_id: toText(response?.activeMessageId) || null,
1284
+ ...(notes[acknowledgement] ? { note: notes[acknowledgement] } : {}),
1285
+ };
1286
+ }
1287
+
1288
+ async function archiveSession(ctx, args) {
1289
+ const response = await call(ctx, 'POST', sessionPath(args.session, '/archive'), { body: {} });
1290
+ const gone = response?.alreadyDeleted === true;
1291
+ return {
1292
+ summary: `archive → ${ctx.relay.name} session ${shortId(args.session)}: ${gone ? 'already deleted' : 'archived'}`,
1293
+ session: args.session,
1294
+ archived: !gone,
1295
+ ...(gone ? { note: 'The session was already deleted there.' } : {}),
1296
+ };
1297
+ }
1298
+
1299
+ const ACTIONS = Object.freeze({
1300
+ relay_info: relayInfo,
1301
+ list_sessions: listSessions,
1302
+ read_session: readSession,
1303
+ wait: waitAction,
1304
+ send,
1305
+ create_session: createSession,
1306
+ answer_question: answerQuestion,
1307
+ stop: stopTurn,
1308
+ archive: archiveSession,
1309
+ });
1310
+
1311
+ // ─── Errors ────────────────────────────────────────────────────────────────
1312
+
1313
+ function mapRemoteError(error, relay, { action, args }) {
1314
+ const C = REMOTE_RELAY_ERROR_CODES;
1315
+ const code = toText(error?.code);
1316
+ const status = Number(error?.status) || null;
1317
+ const message = toText(error?.message) || `Relay "${relay.name}" did not answer as expected`;
1318
+ const remoteBody = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
1319
+ const extra = { relay: relay.name };
1320
+ if (code === C.offline) return failure(502, C.offline, message, extra);
1321
+ if (code === C.unauthorized || status === 401) {
1322
+ return failure(502, C.unauthorized, `${message}. Check this relay in Settings → Relays (token).`, extra);
1323
+ }
1324
+ if (code === C.inboundDisabled || toText(remoteBody?.code) === C.inboundDisabled) {
1325
+ return failure(403, C.inboundDisabled, message, extra);
1326
+ }
1327
+ if (code === C.notFound || status === 404) {
1328
+ const what = action === 'answer_question'
1329
+ ? `Question ${args.question_id}`
1330
+ : (args.session ? `Session ${args.session}` : '');
1331
+ return failure(404, C.notFound, what ? `${what} was not found on relay "${relay.name}".` : message, extra);
1332
+ }
1333
+ const remoteCode = toText(remoteBody?.code) || null;
1334
+ const supportedModels = Array.isArray(remoteBody?.supportedModels) ? remoteBody.supportedModels.slice(0, 50) : null;
1335
+ const details = {
1336
+ ...extra,
1337
+ ...(remoteCode ? { remoteCode } : {}),
1338
+ ...(supportedModels ? { supportedModels } : {}),
1339
+ };
1340
+ if (code.startsWith(C.httpPrefix) && status >= 400 && status < 500) return failure(status, code, message, details);
1341
+ return failure(502, code || `${C.httpPrefix}502`, message, details);
1342
+ }
1343
+
1344
+ // ─── Pipeline ──────────────────────────────────────────────────────────────
1345
+
1346
+ async function listRelays(conversationId) {
1347
+ const relays = (await registry.list()) || [];
1348
+ const listed = relays.map((relay) => ({
1349
+ name: relay.name,
1350
+ url: relay.url,
1351
+ online: toText(relay.lastStatus).toLowerCase() === 'online',
1352
+ version: relay.version || null,
1353
+ permission: relay.permission,
1354
+ unlocked: isUnlocked(conversationId, relay.id),
1355
+ }));
1356
+ const unlocked = listed.filter((relay) => relay.unlocked).length;
1357
+ return {
1358
+ status: 200,
1359
+ body: {
1360
+ ok: true,
1361
+ summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
1362
+ self: { name: (await registry.selfName?.()) || null },
1363
+ relays: listed,
1364
+ hint: 'Mention @name in the chat to unlock a relay.',
1365
+ },
1366
+ };
1367
+ }
1368
+
1369
+ async function runRelayAction({ conversationId, action, args, req, signal }) {
1370
+ const resolved = await registry.resolve(args.relay);
1371
+ if (!resolved?.relay) {
1372
+ const names = Array.isArray(resolved?.names) ? resolved.names : [];
1373
+ const error = resolved?.error === 'ambiguous'
1374
+ ? `"${args.relay}" matches several relays (${names.join(', ')}); use the full name.`
1375
+ : `No paired relay is called "${args.relay}". Known relays: ${names.join(', ') || 'none'}.`;
1376
+ return failure(404, REMOTE_RELAY_ERROR_CODES.unknown, error, { relays: names });
1377
+ }
1378
+ const relay = resolved.relay;
1379
+ const write = isRemoteRelayWriteAction(action);
1380
+ const finish = (result, session) => {
1381
+ if (write) {
1382
+ const ok = result.body?.ok === true;
1383
+ auditWrite({
1384
+ conversationId,
1385
+ action,
1386
+ relay,
1387
+ session: session || result.body?.session || args.session,
1388
+ outcome: ok ? (toText(result.body.status) || 'ok') : result.body?.code,
1389
+ ok,
1390
+ });
1391
+ }
1392
+ return result;
1393
+ };
1394
+
1395
+ if (!isUnlocked(conversationId, relay.id)) {
1396
+ return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
1397
+ `Relay "${relay.name}" is locked in this conversation. Ask the user to mention @${relay.name} (or its name) in a message to allow work there.`,
1398
+ { relay: relay.name }));
1399
+ }
1400
+ if (!remoteRelayPermissionAllows(relay.permission, action)) {
1401
+ return finish(failure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
1402
+ `Relay "${relay.name}" lets agents ${PERMISSION_DESCRIPTIONS[relay.permission] || 'do less'} (permission "${relay.permission}"); ${action} needs "${REMOTE_RELAY_ACTION_PERMISSION[action]}". The user can change this in Settings → Relays.`,
1403
+ { relay: relay.name, permission: relay.permission }));
1404
+ }
1405
+
1406
+ const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
1407
+ caller.mode = toText(caller.mode).toLowerCase();
1408
+ const hops = nonNegativeInt(caller.hops) + 1;
1409
+ if (hops > REMOTE_RELAY_LIMITS.hopLimit) {
1410
+ return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
1411
+ `This turn was started by another relay's agent (hop ${hops - 1}); forwarding it again would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops.`,
1412
+ { relay: relay.name }));
1413
+ }
1414
+
1415
+ const retryAfterSeconds = consumeRateLimit(conversationId, write);
1416
+ if (retryAfterSeconds !== null) {
1417
+ return finish(failure(429, REMOTE_RELAY_ERROR_CODES.rateLimited,
1418
+ `Too many remote relay calls from this conversation (at most ${REMOTE_RELAY_LIMITS.callsPerMinute} a minute, ${REMOTE_RELAY_LIMITS.writesPerMinute} of them writes). Try again in ${retryAfterSeconds} s.`,
1419
+ { relay: relay.name, retryAfterSeconds }));
1420
+ }
1421
+
1422
+ adjustInflight(conversationId, 1);
1423
+ try {
1424
+ if (write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
1425
+ const decision = await requestApproval({
1426
+ conversationId,
1427
+ callerContext: caller,
1428
+ relay: relayTarget(relay),
1429
+ action,
1430
+ args,
1431
+ summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1432
+ signal,
1433
+ });
1434
+ const approved = decision === true || decision?.approved === true;
1435
+ if (!approved) {
1436
+ const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
1437
+ const error = toText(decision?.error)
1438
+ || `The user did not allow this ${action.replace(/_/g, ' ')} on relay "${relay.name}".`;
1439
+ return finish(failure(403, code, error, { relay: relay.name }));
1440
+ }
1441
+ }
1442
+
1443
+ const ctx = { relay, caller, hops, conversationId, signal };
1444
+ const body = await ACTIONS[action](ctx, args);
1445
+ const { summary, ...rest } = body;
1446
+ return finish({
1447
+ status: 200,
1448
+ body: {
1449
+ ok: true,
1450
+ relay: relay.name,
1451
+ summary: summary || summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1452
+ ...rest,
1453
+ },
1454
+ });
1455
+ } catch (error) {
1456
+ if (error instanceof DispatchFailure) {
1457
+ return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
1458
+ }
1459
+ if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
1460
+ throw error;
1461
+ } finally {
1462
+ adjustInflight(conversationId, -1);
1463
+ }
1464
+ }
1465
+
1466
+ /**
1467
+ * One tool call. Never throws: resolves to `{ status, body }` for the route
1468
+ * to send as is. `signal` (optional) aborts approval and reply waits when
1469
+ * the caller hangs up.
1470
+ */
1471
+ async function dispatch({ conversationId, action, args, req, signal } = {}) {
1472
+ const validation = validateRemoteRelayToolInput({ ...(isPlainObject(args) ? args : {}), action });
1473
+ if (!validation.ok) return failure(400, validation.code, validation.error);
1474
+ const requestedConversationId = toText(conversationId);
1475
+ let callerConversationId = requestedConversationId;
1476
+ try {
1477
+ callerConversationId = toText(resolveConversationId(requestedConversationId)) || requestedConversationId;
1478
+ } catch {}
1479
+ if (!callerConversationId) {
1480
+ return failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'conversationId is required');
1481
+ }
1482
+ try {
1483
+ if (validation.action === 'list_relays') return await listRelays(callerConversationId);
1484
+ return await runRelayAction({
1485
+ conversationId: callerConversationId,
1486
+ action: validation.action,
1487
+ args: validation.args,
1488
+ req,
1489
+ signal,
1490
+ });
1491
+ } catch (error) {
1492
+ try {
1493
+ logger?.warn?.(`${LOG_PREFIX} ${validation.action} failed conv=${shortId(callerConversationId)}: ${toText(error?.message || error).slice(0, 200)}`);
1494
+ } catch {}
1495
+ return failure(500, REMOTE_RELAY_INTERNAL_ERROR_CODE, 'The remote relay tool failed on this relay; see its server log.');
1496
+ }
1497
+ }
1498
+
1499
+ return { dispatch, inflight };
1500
+ }