@oar-sh/oar 0.9.3 → 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 (68) 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 +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Deferred host suspend: a "💤 Suspend host" request is queued and fulfilled
5
+ * only once nothing is running any more — no queued/processing turn, no live
6
+ * background task, no open CI run of a watched workspace — for a sustained
7
+ * idle window. Idle at request time means a short countdown instead, so the
8
+ * banner still gives the user a moment to cancel.
9
+ *
10
+ * Everything lives in memory: a relay restart drops the request (dispose()
11
+ * reports it so the caller can push a notice). The service is pure
12
+ * orchestration; the activity collector, the suspend command and the timers
13
+ * are injected so the state machine is unit-testable with fake clocks.
14
+ */
15
+
16
+ export const HOST_SUSPEND_IDLE_WINDOW_MS = 2 * 60 * 1000;
17
+ export const HOST_SUSPEND_IDLE_COUNTDOWN_MS = 30 * 1000;
18
+ const DEFAULT_POLL_MS = 1000;
19
+
20
+ export const HOST_SUSPEND_STATUSES = Object.freeze(['idle', 'queued', 'countdown', 'suspending']);
21
+
22
+ function clampText(value, max, fallback) {
23
+ const text = String(value ?? '').trim().slice(0, max);
24
+ return text || fallback;
25
+ }
26
+
27
+ /**
28
+ * Normalise a blocker so the client can render it without guessing. Blockers
29
+ * with the same `kind` and `detail` are treated as identical when deciding
30
+ * whether the state changed.
31
+ */
32
+ export function normalizeHostActivityBlocker(raw) {
33
+ if (!raw || typeof raw !== 'object') return null;
34
+ const kind = String(raw.kind || '').trim();
35
+ if (!kind) return null;
36
+ const count = Number(raw.count);
37
+ return {
38
+ kind,
39
+ conversationId: raw.conversationId ? String(raw.conversationId) : null,
40
+ title: raw.title ? String(raw.title).slice(0, 160) : null,
41
+ count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : null,
42
+ detail: String(raw.detail || '').slice(0, 400),
43
+ };
44
+ }
45
+
46
+ export function blockersFromActivity(activity) {
47
+ const list = Array.isArray(activity?.blockers) ? activity.blockers : Array.isArray(activity) ? activity : [];
48
+ return list.map(normalizeHostActivityBlocker).filter(Boolean);
49
+ }
50
+
51
+ export function createHostSuspendService({
52
+ collectActivity,
53
+ runSuspend,
54
+ onStateChange = null,
55
+ onSuspended = null,
56
+ onDropped = null,
57
+ logger = console,
58
+ now = () => Date.now(),
59
+ setIntervalImpl = setInterval,
60
+ clearIntervalImpl = clearInterval,
61
+ pollMs = DEFAULT_POLL_MS,
62
+ idleWindowMs = HOST_SUSPEND_IDLE_WINDOW_MS,
63
+ idleCountdownMs = HOST_SUSPEND_IDLE_COUNTDOWN_MS,
64
+ } = {}) {
65
+ if (typeof collectActivity !== 'function') throw new Error('collectActivity is required');
66
+ if (typeof runSuspend !== 'function') throw new Error('runSuspend is required');
67
+
68
+ let status = 'idle';
69
+ let request = null;
70
+ let blockers = [];
71
+ let idleSince = null;
72
+ let fireAt = null;
73
+ let everBlocked = false;
74
+ let checkedAt = null;
75
+ let lastError = null;
76
+ let pollTimer = null;
77
+ let lastEmittedKey = '';
78
+ let ticking = false;
79
+
80
+ function publicState() {
81
+ return {
82
+ status,
83
+ pending: status === 'queued' || status === 'countdown' || status === 'suspending',
84
+ reason: request?.reason || null,
85
+ requestedBy: request?.requestedBy || null,
86
+ requestedAt: request?.requestedAt || null,
87
+ idleSince: idleSince ? new Date(idleSince).toISOString() : null,
88
+ fireAt: fireAt ? new Date(fireAt).toISOString() : null,
89
+ idleWindowMs: everBlocked ? idleWindowMs : idleCountdownMs,
90
+ checkedAt: checkedAt ? new Date(checkedAt).toISOString() : null,
91
+ blockers: blockers.map((b) => ({ ...b })),
92
+ lastError,
93
+ };
94
+ }
95
+
96
+ function emit(force = false) {
97
+ const state = publicState();
98
+ // Countdowns tick on the client from fireAt; only real transitions go out.
99
+ const key = JSON.stringify([
100
+ state.status,
101
+ state.requestedAt,
102
+ state.fireAt,
103
+ state.blockers.map((b) => `${b.kind}|${b.conversationId}|${b.count}|${b.detail}`),
104
+ state.lastError,
105
+ ]);
106
+ if (!force && key === lastEmittedKey) return;
107
+ lastEmittedKey = key;
108
+ try { onStateChange?.(state); } catch (error) {
109
+ try { logger.warn?.(`[host-suspend] state listener failed: ${error?.message || error}`); } catch {}
110
+ }
111
+ }
112
+
113
+ function stopPolling() {
114
+ if (!pollTimer) return;
115
+ try { clearIntervalImpl(pollTimer); } catch {}
116
+ pollTimer = null;
117
+ }
118
+
119
+ function resetToIdle() {
120
+ status = 'idle';
121
+ request = null;
122
+ blockers = [];
123
+ idleSince = null;
124
+ fireAt = null;
125
+ everBlocked = false;
126
+ lastError = null;
127
+ stopPolling();
128
+ }
129
+
130
+ function collect() {
131
+ try {
132
+ const activity = collectActivity({ request: request ? { ...request } : null });
133
+ lastError = null;
134
+ return blockersFromActivity(activity);
135
+ } catch (error) {
136
+ // An unreadable activity picture must not sleep the box under running
137
+ // agents: report it as a blocker until the collector recovers.
138
+ lastError = String(error?.message || error || 'activity check failed').slice(0, 300);
139
+ return [{
140
+ kind: 'error',
141
+ conversationId: null,
142
+ title: null,
143
+ count: null,
144
+ detail: `Activity check failed: ${lastError}`,
145
+ }];
146
+ }
147
+ }
148
+
149
+ function evaluate() {
150
+ if (status !== 'queued' && status !== 'countdown') return;
151
+ const at = now();
152
+ checkedAt = at;
153
+ blockers = collect();
154
+ if (blockers.length) {
155
+ everBlocked = true;
156
+ idleSince = null;
157
+ fireAt = null;
158
+ status = 'queued';
159
+ emit();
160
+ return;
161
+ }
162
+ if (!idleSince) idleSince = at;
163
+ const windowMs = everBlocked ? idleWindowMs : idleCountdownMs;
164
+ fireAt = idleSince + windowMs;
165
+ status = 'countdown';
166
+ if (at >= fireAt) {
167
+ fire();
168
+ return;
169
+ }
170
+ emit();
171
+ }
172
+
173
+ function fire() {
174
+ status = 'suspending';
175
+ stopPolling();
176
+ const state = publicState();
177
+ emit(true);
178
+ try { logger.log?.(`[host-suspend] everything idle for ${Math.round((state.idleWindowMs || 0) / 1000)} s; suspending host (requested by ${state.requestedBy || 'unknown'})`); } catch {}
179
+ let result = null;
180
+ try {
181
+ result = runSuspend({ state });
182
+ } catch (error) {
183
+ lastError = String(error?.message || error || 'suspend failed').slice(0, 300);
184
+ try { logger.warn?.(`[host-suspend] suspend command failed: ${lastError}`); } catch {}
185
+ }
186
+ const failed = result && result.ok === false;
187
+ if (failed && !lastError) lastError = String(result.error || 'suspend failed').slice(0, 300);
188
+ const finalError = lastError;
189
+ resetToIdle();
190
+ lastError = finalError;
191
+ emit(true);
192
+ if (!finalError) {
193
+ try { onSuspended?.(state); } catch {}
194
+ }
195
+ }
196
+
197
+ function tick() {
198
+ if (ticking) return;
199
+ ticking = true;
200
+ try { evaluate(); } finally { ticking = false; }
201
+ }
202
+
203
+ function requestSuspend({ reason = 'manual-suspend', requestedBy = 'localhost-api' } = {}) {
204
+ if (status === 'suspending') return { accepted: false, state: publicState() };
205
+ if (status === 'queued' || status === 'countdown') {
206
+ return { accepted: false, alreadyPending: true, state: publicState() };
207
+ }
208
+ request = {
209
+ reason: clampText(reason, 140, 'manual-suspend'),
210
+ requestedBy: clampText(requestedBy, 80, 'localhost-api'),
211
+ requestedAt: new Date(now()).toISOString(),
212
+ };
213
+ everBlocked = false;
214
+ idleSince = null;
215
+ fireAt = null;
216
+ lastError = null;
217
+ status = 'queued';
218
+ evaluate();
219
+ if (status === 'queued' || status === 'countdown') {
220
+ pollTimer = setIntervalImpl(tick, pollMs);
221
+ pollTimer?.unref?.();
222
+ }
223
+ return { accepted: true, state: publicState() };
224
+ }
225
+
226
+ function cancel({ requestedBy = 'localhost-api' } = {}) {
227
+ if (status !== 'queued' && status !== 'countdown') {
228
+ return { cancelled: false, state: publicState() };
229
+ }
230
+ try { logger.log?.(`[host-suspend] queued suspend cancelled by ${clampText(requestedBy, 80, 'unknown')}`); } catch {}
231
+ resetToIdle();
232
+ emit(true);
233
+ return { cancelled: true, state: publicState() };
234
+ }
235
+
236
+ function dispose({ reason = 'shutdown' } = {}) {
237
+ const wasPending = status === 'queued' || status === 'countdown';
238
+ const state = publicState();
239
+ resetToIdle();
240
+ if (wasPending) {
241
+ try { logger.log?.(`[host-suspend] queued suspend dropped (${reason})`); } catch {}
242
+ try { onDropped?.(state, reason); } catch {}
243
+ }
244
+ }
245
+
246
+ return {
247
+ request: requestSuspend,
248
+ cancel,
249
+ getState: publicState,
250
+ /** Re-run the activity check now (used by the status/GET routes). */
251
+ refresh() { if (status === 'queued' || status === 'countdown') tick(); return publicState(); },
252
+ dispose,
253
+ };
254
+ }
@@ -10,7 +10,7 @@
10
10
  * reports outcomes through the status-events store instead.
11
11
  */
12
12
 
13
- export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline']);
13
+ export const PUSH_EVENT_TYPES = Object.freeze(['question', 'turnComplete', 'turnFailed', 'board', 'cliOffline', 'hostSuspend']);
14
14
 
15
15
  export const VAPID_PUBLIC_KEY_SETTING = 'push_vapid_public_key';
16
16
  export const VAPID_PRIVATE_KEY_SETTING = 'push_vapid_private_key';
@@ -34,8 +34,13 @@ const EVENT_LABELS = Object.freeze({
34
34
  turnFailed: { title: 'Turn failed', generic: 'The agent turn failed.' },
35
35
  board: { title: 'Plan ready for review', generic: 'The agent posted a board for review.' },
36
36
  cliOffline: { title: 'CLI offline', generic: 'The relay lost its connection to the CLI host.' },
37
+ hostSuspend: { title: 'Host suspend', generic: 'The queued host suspend changed state.' },
37
38
  });
38
39
 
40
+ // System events carry their own body: no conversation text to preview, and
41
+ // the body is the message itself regardless of the preview preference.
42
+ const SYSTEM_BODY_TYPES = new Set(['hostSuspend']);
43
+
39
44
  function clampInt(value, min, max, fallback) {
40
45
  const numeric = Number(value);
41
46
  if (!Number.isFinite(numeric)) return fallback;
@@ -90,7 +95,9 @@ export function renderPushNotification(event, preferences) {
90
95
  let body = labels.generic;
91
96
  const text = String(event?.body || '').trim();
92
97
  // cliOffline has no conversation text to preview; the generic body is the message.
93
- if (text && type !== 'cliOffline') {
98
+ if (text && SYSTEM_BODY_TYPES.has(type)) {
99
+ body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
100
+ } else if (text && type !== 'cliOffline') {
94
101
  if (prefs.content.preview === 'full') {
95
102
  body = text.slice(0, MAX_FULL_PREVIEW_CHARS);
96
103
  } else if (prefs.content.preview === 'truncated') {
@@ -300,6 +307,23 @@ export function createPushDispatchService({
300
307
  } catch {}
301
308
  }
302
309
 
310
+ // A turn another relay's agent started (its user message carries `origin`)
311
+ // pushes no "reply ready" or "failed": that agent is the one waiting for the
312
+ // reply and reports back to the user itself (decision 22). Question pushes
313
+ // are unchanged. Prepared lazily: a schema without the column (before
314
+ // migration 0005) simply means "not an agent's turn".
315
+ let messageOriginStatement = null;
316
+ function turnStartedByRemoteAgent(sourceMessageId) {
317
+ const id = String(sourceMessageId || '').trim();
318
+ if (!id) return false;
319
+ try {
320
+ messageOriginStatement ??= db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
321
+ return String(messageOriginStatement.get(id)?.origin_json || '').trim() !== '';
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+
303
327
  async function sendToSubscription(row, event) {
304
328
  const preferences = normalizePushPreferences(parseJsonObject(row.preferences_json));
305
329
  const payload = renderPushNotification(event, preferences);
@@ -412,7 +436,10 @@ export function createPushDispatchService({
412
436
  });
413
437
  }
414
438
 
415
- function notifyTurnComplete({ conversationId, messageId, text } = {}) {
439
+ // `messageId` is the reply; `sourceMessageId` the user message that started
440
+ // the turn.
441
+ function notifyTurnComplete({ conversationId, messageId, sourceMessageId, text } = {}) {
442
+ if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
416
443
  return dispatch({
417
444
  type: 'turnComplete',
418
445
  conversationId: conversationId || null,
@@ -421,7 +448,8 @@ export function createPushDispatchService({
421
448
  });
422
449
  }
423
450
 
424
- function notifyTurnFailed({ conversationId, messageId, text } = {}) {
451
+ function notifyTurnFailed({ conversationId, messageId, sourceMessageId, text } = {}) {
452
+ if (turnStartedByRemoteAgent(sourceMessageId)) return Promise.resolve({ outcome: 'remote-agent' });
425
453
  return dispatch({
426
454
  type: 'turnFailed',
427
455
  conversationId: conversationId || null,
@@ -443,7 +471,12 @@ export function createPushDispatchService({
443
471
  return dispatch({ type: 'cliOffline' });
444
472
  }
445
473
 
474
+ function notifyHostSuspend({ body = null } = {}) {
475
+ return dispatch({ type: 'hostSuspend', body: body || null, data: { hostSuspendAt: Date.now() } });
476
+ }
477
+
446
478
  return {
479
+ notifyHostSuspend,
447
480
  listDevices,
448
481
  getDevice,
449
482
  upsertSubscription,
@@ -0,0 +1,341 @@
1
+ 'use strict';
2
+
3
+ import { randomUUID as nodeRandomUUID } from 'crypto';
4
+
5
+ import {
6
+ REMOTE_RELAY_APPROVAL_SOURCE,
7
+ REMOTE_RELAY_ERROR_CODES,
8
+ REMOTE_RELAY_PROVIDERS,
9
+ } from '../../shared/remote-relay-contract.mjs';
10
+ import { DEFAULT_QUESTION_TIMEOUT_MS, questionExpiresAt } from '../../shared/question-timeout.mjs';
11
+ import { createQuestionRepository } from '../repositories/question-repository.mjs';
12
+ import { pickLiveTurnRowId } from './live-turn-picker.mjs';
13
+ import { sanitizeRelayQuestionContext } from './relay-question-context.mjs';
14
+
15
+ // The runtime side of the remote-relay dispatcher: who is calling (the
16
+ // conversation, its current turn, provider, model, relay mode and hop count)
17
+ // and the approval card that write actions need in ask/plan mode.
18
+ //
19
+ // A tool call arrives while the calling session's turn is processing, so the
20
+ // turn is that conversation's live processing queue row. Its id is also the
21
+ // id of the user message behind it (POST /api/message writes both with one
22
+ // id), which is where an agent-sent prompt keeps its `origin` and hop count.
23
+ //
24
+ // A background continuation turn (queue kind 'continuation') has no user
25
+ // message behind it; it continues work an earlier turn started, so it counts
26
+ // the hops of the latest user message before it.
27
+ //
28
+ // The approval card is an ordinary relay question on that row, written the
29
+ // way POST /api/relay-question writes one: same columns, same socket event and
30
+ // push, so it renders like any ask_user card and the stale-recovery and turn
31
+ // ceiling exemptions for a turn waiting on a question apply to it. Its context
32
+ // source (REMOTE_RELAY_APPROVAL_SOURCE) keeps other relays' agents away from it.
33
+
34
+ const LOG_PREFIX = '[remote-relays]';
35
+ const APPROVAL_HEADER = 'Remote relay';
36
+ const APPROVAL_ALLOW = 'Allow';
37
+ const APPROVAL_DENY = 'Deny';
38
+ const EXCERPT_MAX = 200;
39
+
40
+ function toText(value) {
41
+ return String(value ?? '').trim();
42
+ }
43
+
44
+ function shortId(value) {
45
+ return toText(value).slice(0, 8) || '-';
46
+ }
47
+
48
+ function defaultNormalizeRelayMode(mode) {
49
+ return toText(mode).toLowerCase() || null;
50
+ }
51
+
52
+ function parseJson(value, fallback) {
53
+ if (!value) return fallback;
54
+ try {
55
+ return JSON.parse(value);
56
+ } catch {
57
+ return fallback;
58
+ }
59
+ }
60
+
61
+ /** Enough of the runtime's formatQuestionRow for the card and the tests. */
62
+ function defaultFormatQuestionRow(row) {
63
+ if (!row) return null;
64
+ const envelope = parseJson(row.request, null);
65
+ return {
66
+ id: row.id,
67
+ queueId: row.queue_id,
68
+ conversationId: row.conversation_id,
69
+ sdkSessionId: row.sdk_session_id || null,
70
+ messageId: row.message_id,
71
+ mode: row.relay_mode,
72
+ prompt: row.prompt,
73
+ choices: parseJson(row.choices, []),
74
+ context: envelope && typeof envelope === 'object' ? envelope.context || null : null,
75
+ allowFreeform: envelope?.allowFreeform ?? false,
76
+ status: row.status,
77
+ answer: row.answer || null,
78
+ createdAt: row.created_at,
79
+ answeredAt: row.answered_at || null,
80
+ expiresAt: row.expires_at,
81
+ };
82
+ }
83
+
84
+ function excerpt(value) {
85
+ const text = toText(value).replace(/\s+/g, ' ');
86
+ return text.length > EXCERPT_MAX ? `${text.slice(0, EXCERPT_MAX - 1)}…` : text;
87
+ }
88
+
89
+ const APPROVAL_VERBS = Object.freeze({
90
+ send: 'send a prompt',
91
+ create_session: 'start a new session',
92
+ answer_question: 'answer a question',
93
+ stop: 'stop the running turn',
94
+ archive: 'archive a session',
95
+ });
96
+
97
+ /**
98
+ * How the remote turn would run, as far as this relay knows it: the mode (the
99
+ * requested one, else the calling session's, which the dispatcher passes on),
100
+ * the provider a new session gets (requested, else the caller's), and the
101
+ * model and working directory when the agent asked for them.
102
+ */
103
+ function describeRemoteTurn(action, args, caller) {
104
+ const parts = [];
105
+ const mode = toText(args.mode).toLowerCase() || toText(caller?.mode).toLowerCase();
106
+ if (mode) parts.push(`Mode: ${mode}`);
107
+ if (action === 'create_session') {
108
+ const requested = toText(args.provider).toLowerCase();
109
+ const callerProvider = toText(caller?.provider).toLowerCase();
110
+ const provider = requested || (REMOTE_RELAY_PROVIDERS.includes(callerProvider) ? callerProvider : 'github');
111
+ parts.push(`Provider: ${provider}`);
112
+ }
113
+ if (toText(args.model)) parts.push(`Model: ${excerpt(args.model)}`);
114
+ if (toText(args.cwd)) parts.push(`Folder: ${excerpt(args.cwd)}`);
115
+ return parts.join(' · ');
116
+ }
117
+
118
+ /**
119
+ * The card text: what the agent wants to do where, how the remote turn would
120
+ * run, then the prompt (or the answer) it wants to send, so the user decides
121
+ * on the actual words. `caller` is the calling session's context.
122
+ */
123
+ export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null } = {}) {
124
+ const name = toText(relay?.name) || 'a remote relay';
125
+ const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
126
+ const target = args.session ? ` (session ${shortId(args.session)})` : '';
127
+ const lines = [`Allow the agent to ${verb} on relay "${name}"${target}?`];
128
+ if (action === 'send' || action === 'create_session') {
129
+ const details = describeRemoteTurn(action, args, caller);
130
+ if (details) lines.push(details);
131
+ const text = excerpt(args.text);
132
+ if (text) lines.push(`“${text}”`);
133
+ } else if (action === 'answer_question') {
134
+ const answer = excerpt([...(Array.isArray(args.choices) ? args.choices : []), args.answer].filter(Boolean).join(', '));
135
+ if (answer) lines.push(`Answer: “${answer}”`);
136
+ }
137
+ return lines.join('\n\n');
138
+ }
139
+
140
+ export function createRemoteRelayCallerContext({
141
+ db,
142
+ repository,
143
+ questions = null,
144
+ resolveLiveTurnQueueRow = null,
145
+ formatQuestionRow = defaultFormatQuestionRow,
146
+ normalizeRelayMode = defaultNormalizeRelayMode,
147
+ defaultRelayMode = 'agent',
148
+ emit = () => {},
149
+ notifyQuestion = () => {},
150
+ uuid = nodeRandomUUID,
151
+ now = () => new Date(),
152
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
153
+ logger = console,
154
+ questionPollMs = 1000,
155
+ questionTimeoutMs = DEFAULT_QUESTION_TIMEOUT_MS,
156
+ } = {}) {
157
+ const questionStatements = questions || createQuestionRepository(db);
158
+ const getConversation = db.prepare(`SELECT id, title, preferred_relay_mode, preferred_model FROM conversations WHERE id = ?`);
159
+ const getRuntimeSession = db.prepare(`SELECT provider_type, provider_model, model FROM runtime_sessions WHERE conversation_id = ?`);
160
+ const listProcessingRows = db.prepare(`
161
+ SELECT id, kind, relay_mode, model, attempt_id, owner_sdk_session_id, timestamp, processing_at
162
+ FROM queue
163
+ WHERE conversation_id = ? AND status = 'processing'
164
+ ORDER BY COALESCE(processing_at, timestamp) ASC
165
+ `);
166
+ const getQueueRow = db.prepare(`SELECT * FROM queue WHERE id = ?`);
167
+ const getLatestUserMessage = db.prepare(`
168
+ SELECT id FROM messages WHERE conversation_id = ? AND role = 'user' ORDER BY timestamp DESC LIMIT 1
169
+ `);
170
+ const getLatestUserMessageAtOrBefore = db.prepare(`
171
+ SELECT id FROM messages WHERE conversation_id = ? AND role = 'user' AND timestamp <= ? ORDER BY timestamp DESC LIMIT 1
172
+ `);
173
+
174
+ function normalizeMode(mode) {
175
+ return normalizeRelayMode(mode) || defaultRelayMode;
176
+ }
177
+
178
+ /** The live turn: the runtime's own picker when given, else the same rule. */
179
+ function liveTurnRow(conversationId, rows) {
180
+ if (!rows.length) return null;
181
+ if (typeof resolveLiveTurnQueueRow === 'function') {
182
+ const live = resolveLiveTurnQueueRow(conversationId);
183
+ const match = live && rows.find((row) => row.id === live.id);
184
+ if (match) return match;
185
+ }
186
+ if (rows.length === 1) return rows[0];
187
+ const liveId = pickLiveTurnRowId(rows.map((row) => {
188
+ const output = questionStatements.getLastOutputAtByQueueMessage?.get({ id: row.id }) || null;
189
+ return {
190
+ id: String(row.id),
191
+ lastOutputAtMs: Date.parse(String(output?.last_output_at || '')) || 0,
192
+ streamDone: Number(output?.stream_done || 0) === 1,
193
+ processingAtMs: Date.parse(String(row.processing_at || row.timestamp || '')) || 0,
194
+ };
195
+ }));
196
+ return rows.find((row) => row.id === liveId) || rows[0];
197
+ }
198
+
199
+ function originHops(messageId) {
200
+ const origin = messageId ? repository?.getMessageOrigin?.(messageId) : null;
201
+ const hops = Number(origin?.hops);
202
+ return Number.isInteger(hops) && hops >= 0 ? hops : 0;
203
+ }
204
+
205
+ /**
206
+ * A turn's hops: those of its own user message, or for a continuation row
207
+ * (no message of its own) those of the latest user message before it, the
208
+ * turn whose background work it continues.
209
+ */
210
+ function rowHops(conversationId, row) {
211
+ if (toText(row.kind).toLowerCase() !== 'continuation') return originHops(row.id);
212
+ const at = toText(row.timestamp) || toText(row.processing_at);
213
+ const message = at
214
+ ? getLatestUserMessageAtOrBefore.get(conversationId, at)
215
+ : getLatestUserMessage.get(conversationId);
216
+ return originHops(message?.id);
217
+ }
218
+
219
+ /**
220
+ * `{ conversationId, title, provider, model, mode, processingRowId,
221
+ * attemptId, userMessageId, hops }` for the conversation making the call.
222
+ * `hops` is how many relays the current turn's prompt already crossed: 0
223
+ * for a human's message. A steered message folded into the live turn is
224
+ * part of what the agent is acting on, so the highest count among the
225
+ * conversation's processing rows wins (a background continuation counts as
226
+ * the turn it continues); without a turn (an off-turn call) the latest user
227
+ * message decides.
228
+ */
229
+ function getCallerContext(conversationId) {
230
+ const id = toText(conversationId);
231
+ const conversation = getConversation.get(id) || null;
232
+ const runtime = getRuntimeSession.get(id) || null;
233
+ const rows = listProcessingRows.all(id);
234
+ const live = liveTurnRow(id, rows);
235
+ const hops = rows.length
236
+ ? Math.max(...rows.map((row) => rowHops(id, row)))
237
+ : originHops(getLatestUserMessage.get(id)?.id);
238
+ return {
239
+ conversationId: id,
240
+ title: toText(conversation?.title),
241
+ provider: toText(runtime?.provider_type).toLowerCase() || 'github',
242
+ model: toText(live?.model) || toText(runtime?.provider_model) || toText(runtime?.model) || toText(conversation?.preferred_model) || '',
243
+ mode: normalizeMode(live?.relay_mode || conversation?.preferred_relay_mode),
244
+ processingRowId: live?.id || null,
245
+ attemptId: toText(live?.attempt_id) || null,
246
+ userMessageId: live?.id || null,
247
+ hops,
248
+ };
249
+ }
250
+
251
+ function formatQuestion(questionId) {
252
+ return formatQuestionRow(questionStatements.getQuestion.get(questionId));
253
+ }
254
+
255
+ function withdraw(questionId) {
256
+ try {
257
+ questionStatements.timeoutQuestion.run(questionId);
258
+ emit('relay_question_updated', { question: formatQuestion(questionId) });
259
+ } catch {}
260
+ }
261
+
262
+ /**
263
+ * Puts an Allow / Deny card on the calling turn and waits for the user.
264
+ * Resolves `{ approved:true }`, or `{ approved:false, code?, error? }`:
265
+ * denied, timed out, cancelled with the turn, or no turn to ask on (fails
266
+ * closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
267
+ * lives (8 h by default); `signal` withdraws the card early.
268
+ */
269
+ async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
270
+ const rowId = toText(callerContext?.processingRowId);
271
+ const row = rowId ? getQueueRow.get(rowId) : null;
272
+ if (!row || row.status !== 'processing') {
273
+ return {
274
+ approved: false,
275
+ code: REMOTE_RELAY_ERROR_CODES.noTurn,
276
+ error: 'This write action needs the user\'s approval, but the conversation has no running turn to ask on.',
277
+ };
278
+ }
279
+
280
+ const createdAt = now().toISOString();
281
+ const questionId = String(uuid());
282
+ const relayMode = normalizeMode(row.relay_mode);
283
+ const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
284
+ const context = sanitizeRelayQuestionContext({
285
+ source: REMOTE_RELAY_APPROVAL_SOURCE,
286
+ rationale: `Write actions on other relays need approval in ${relayMode} mode.`,
287
+ queueMessageId: row.id,
288
+ conversationId: toText(conversationId) || row.conversation_id,
289
+ relayMode,
290
+ header: APPROVAL_HEADER,
291
+ }, { normalizeRelayMode: normalizeMode, defaultRelayMode });
292
+ questionStatements.insertQuestion.run(
293
+ questionId,
294
+ row.id,
295
+ toText(conversationId) || row.conversation_id,
296
+ row.id,
297
+ relayMode,
298
+ prompt,
299
+ JSON.stringify([APPROVAL_ALLOW, APPROVAL_DENY]),
300
+ JSON.stringify({ request: null, context, allowFreeform: false }),
301
+ null,
302
+ toText(row.owner_sdk_session_id) || null,
303
+ null,
304
+ null,
305
+ null,
306
+ createdAt,
307
+ questionExpiresAt(createdAt, questionTimeoutMs),
308
+ toText(row.attempt_id) || null,
309
+ );
310
+ const question = formatQuestion(questionId);
311
+ try {
312
+ logger?.log?.(`${LOG_PREFIX} approval ${shortId(questionId)} conv=${shortId(row.conversation_id)} ${action} ${toText(relay?.name) || '-'}`);
313
+ } catch {}
314
+ try { emit('relay_question', { question }); } catch {}
315
+ try { void notifyQuestion(question); } catch {}
316
+
317
+ for (;;) {
318
+ const current = questionStatements.getQuestion.get(questionId);
319
+ if (!current) return { approved: false, error: 'The approval question was removed.' };
320
+ if (current.status === 'answered') {
321
+ const approved = toText(current.answer).toLowerCase() === APPROVAL_ALLOW.toLowerCase();
322
+ return approved ? { approved: true } : { approved: false };
323
+ }
324
+ if (current.status !== 'pending') {
325
+ return {
326
+ approved: false,
327
+ error: current.status === 'timed_out'
328
+ ? 'The approval question timed out without an answer.'
329
+ : 'The approval question was cancelled (the turn ended).',
330
+ };
331
+ }
332
+ if (signal?.aborted) {
333
+ withdraw(questionId);
334
+ return { approved: false, error: 'The tool call was abandoned before the user answered.' };
335
+ }
336
+ await sleep(questionPollMs);
337
+ }
338
+ }
339
+
340
+ return { getCallerContext, requestApproval };
341
+ }