@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,46 @@
1
+ // The `context` block a relay question card carries: who asked, why, for which
2
+ // queue row, and the few presentation hints the card renders from. Workers
3
+ // send it on POST /api/relay-question; it is stored inside the question's
4
+ // request envelope and served back to the client. Anything not listed here is
5
+ // dropped, so a new hint the client reads must be added here too (the
6
+ // multi-select flag was once silently stripped this way).
7
+
8
+ /**
9
+ * @param {unknown} rawContext
10
+ * @param {{ normalizeRelayMode?: (mode: string) => string | null, defaultRelayMode?: string }} [options]
11
+ */
12
+ export function sanitizeRelayQuestionContext(rawContext, {
13
+ normalizeRelayMode = (mode) => String(mode || '').trim().toLowerCase() || null,
14
+ defaultRelayMode = 'agent',
15
+ } = {}) {
16
+ if (!rawContext || typeof rawContext !== 'object' || Array.isArray(rawContext)) return null;
17
+ const context = {};
18
+ if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
19
+ context.source = rawContext.source.trim().slice(0, 64);
20
+ }
21
+ if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
22
+ context.rationale = rawContext.rationale.trim().slice(0, 240);
23
+ }
24
+ if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
25
+ context.queueMessageId = rawContext.queueMessageId.trim();
26
+ }
27
+ if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
28
+ context.conversationId = rawContext.conversationId.trim();
29
+ }
30
+ if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
31
+ context.relayMode = normalizeRelayMode(rawContext.relayMode) || defaultRelayMode;
32
+ }
33
+ // The card renders checkmarks instead of one-shot buttons when several
34
+ // answers are allowed (Claude's AskUserQuestion `multiSelect`, or the
35
+ // Copilot worker's wording heuristic). Only an explicit `true` is kept.
36
+ if (rawContext.multiSelect === true) context.multiSelect = true;
37
+ // Providers whose ask tool cannot always say so (Copilot) mark their choice
38
+ // cards with this: the card then offers a "Select several" switch, so the
39
+ // user can pick several answers even when the model did not flag it.
40
+ if (rawContext.allowMultiSelect === true) context.allowMultiSelect = true;
41
+ // Claude's short question label ("Workers", "Visibility").
42
+ if (typeof rawContext.header === 'string' && rawContext.header.trim()) {
43
+ context.header = rawContext.header.trim().slice(0, 120);
44
+ }
45
+ return Object.keys(context).length ? context : null;
46
+ }
@@ -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
+ }