@oar-sh/oar 0.9.4 → 0.9.6

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 (71) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +199 -0
  4. package/README.md +35 -6
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  7. package/server/claude-worker/claude-session-process.mjs +411 -19
  8. package/server/claude-worker/claude-session-worker.mjs +5 -0
  9. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  10. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  11. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  12. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  13. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  14. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  17. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  19. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  20. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  21. package/server/db-schema.mjs +7 -0
  22. package/server/grok-worker/acp-client.mjs +68 -12
  23. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  24. package/server/grok-worker/grok-session-worker.mjs +5 -0
  25. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  26. package/server/public/app/api-client.js +17 -2
  27. package/server/public/app/bootstrap.js +3 -0
  28. package/server/public/app/composer-control-state.mjs +4 -3
  29. package/server/public/app/conversation-view.js +97 -4
  30. package/server/public/app/journal-view.js +6 -0
  31. package/server/public/app/markdown-strikethrough.mjs +31 -0
  32. package/server/public/app/relay-board-inline.mjs +70 -0
  33. package/server/public/app/relay-board-view.js +157 -61
  34. package/server/public/app/relay-error-ctas.mjs +12 -3
  35. package/server/public/app/socket-handlers.js +9 -0
  36. package/server/public/app/steered-turn-merge.mjs +10 -1
  37. package/server/public/app/store.js +4 -1
  38. package/server/public/app/subagent-fold.mjs +61 -0
  39. package/server/public/app/usage-limit-ui.js +225 -0
  40. package/server/public/index.html +97 -4
  41. package/server/repositories/message-repository.mjs +10 -1
  42. package/server/repositories/question-repository.mjs +1 -0
  43. package/server/repositories/remote-relay-repository.mjs +30 -0
  44. package/server/routes/messages-routes.mjs +93 -17
  45. package/server/routes/relay-board-routes.mjs +56 -2
  46. package/server/routes/sessions-routes.mjs +40 -2
  47. package/server/server-runtime.mjs +33 -2
  48. package/server/services/preview-proxy-server.mjs +64 -36
  49. package/server/services/process-list-service.mjs +122 -0
  50. package/server/services/relay-console-log.mjs +199 -0
  51. package/server/services/remote-relay-caller-context.mjs +45 -20
  52. package/server/services/remote-relay-dispatcher.mjs +328 -21
  53. package/server/services/remote-relay-inbound.mjs +35 -0
  54. package/server/services/session-worker-launch-service.mjs +17 -2
  55. package/server/services/session-worker-process-service.mjs +85 -32
  56. package/server/services/session-worker-registry-service.mjs +1 -1
  57. package/server/services/session-worker-supervisor-service.mjs +33 -1
  58. package/server/services/usage-limit-pause-service.mjs +441 -0
  59. package/shared/ask-user-bridge.mjs +14 -39
  60. package/shared/claude-usage-limit.mjs +257 -0
  61. package/shared/control-poller.mjs +69 -20
  62. package/shared/failure-note-text.mjs +38 -0
  63. package/shared/question-wait.mjs +108 -0
  64. package/shared/remote-relay-contract.mjs +33 -2
  65. package/shared/steer-settle-failure.mjs +22 -0
  66. package/shared/steer-settle-markers.mjs +24 -0
  67. package/shared/worker-runtime/process-tree.mjs +483 -0
  68. package/shared/worker-runtime/relay-errors.mjs +31 -0
  69. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  70. package/shared/worker-runtime/turn-liveness.mjs +249 -0
  71. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ // The process table, read the way each platform allows, for the tree watch in
4
+ // shared/worker-runtime/process-tree.mjs. Every entry carries the time the
5
+ // process was started: that is what tells it from a later process that was
6
+ // given its pid.
7
+ //
8
+ // - Linux reads `/proc/<pid>/stat`, which needs no process and takes about
9
+ // 3 ms for 160 processes (measured 2026-09-29). The start time is field 22,
10
+ // in clock ticks since boot.
11
+ // - Windows asks PowerShell, through the list the relay's process inspector
12
+ // already reads. It takes about a second, so it is never polled.
13
+ // - Every other platform asks `ps`, whose start time has a resolution of one
14
+ // second.
15
+
16
+ import fs from 'fs';
17
+ import { execFile } from 'child_process';
18
+
19
+ import { createSessionWorkerProcessInspector } from './session-worker-process-service.mjs';
20
+
21
+ const PS_TIMEOUT_MS = 10_000;
22
+
23
+ /**
24
+ * One `/proc/<pid>/stat`. The name (field 2) is in parentheses and may hold
25
+ * spaces and parentheses of its own, so the fields after it are counted from
26
+ * the LAST closing one. A process that has ended and only waits to be
27
+ * collected (`Z`, `X`) is not running any more.
28
+ */
29
+ export function parseProcStat(text) {
30
+ const raw = String(text || '');
31
+ const open = raw.indexOf('(');
32
+ const close = raw.lastIndexOf(')');
33
+ if (open < 1 || close < open) return null;
34
+ const processId = Number.parseInt(raw.slice(0, open), 10);
35
+ const fields = raw.slice(close + 2).trim().split(/\s+/);
36
+ const state = fields[0] || '';
37
+ const createdAt = Number(fields[19]);
38
+ if (!(processId > 0) || state === 'Z' || state === 'X' || !(createdAt > 0)) return null;
39
+ return {
40
+ processId,
41
+ parentProcessId: Number.parseInt(fields[1], 10) || 0,
42
+ name: raw.slice(open + 1, close),
43
+ createdAt,
44
+ // The runtime starts a command in a session of its own.
45
+ sessionLeader: Number.parseInt(fields[3], 10) === processId,
46
+ };
47
+ }
48
+
49
+ /** One line of `ps -o pid=,ppid=,stat=,lstart=,args=`. */
50
+ export function parsePsLine(line) {
51
+ const match = String(line || '').match(
52
+ /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s*(.*)$/,
53
+ );
54
+ if (!match) return null;
55
+ const createdAt = Date.parse(match[4]);
56
+ if (/^[ZX]/.test(match[3]) || !(createdAt > 0)) return null;
57
+ return {
58
+ processId: Number.parseInt(match[1], 10),
59
+ parentProcessId: Number.parseInt(match[2], 10) || 0,
60
+ createdAt,
61
+ commandLine: match[5],
62
+ };
63
+ }
64
+
65
+ export function createProcessLister({
66
+ platform = process.platform,
67
+ readdirSyncImpl = fs.readdirSync,
68
+ readFileSyncImpl = fs.readFileSync,
69
+ execFileImpl = execFile,
70
+ inspector = null,
71
+ } = {}) {
72
+ if (platform === 'win32') {
73
+ const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl });
74
+ return {
75
+ // A child keeps the pid of its parent after the parent has ended.
76
+ orphansKeepParent: true,
77
+ list: () => windows.readWindowsProcessSnapshot(),
78
+ };
79
+ }
80
+
81
+ if (platform === 'linux') {
82
+ const read = (pid) => {
83
+ try {
84
+ return parseProcStat(readFileSyncImpl(`/proc/${pid}/stat`, 'utf8'));
85
+ } catch {
86
+ // It ended between the directory listing and the read.
87
+ return null;
88
+ }
89
+ };
90
+ return {
91
+ orphansKeepParent: false,
92
+ list: () => readdirSyncImpl('/proc')
93
+ .filter((name) => /^\d+$/.test(String(name)))
94
+ .map((name) => read(name))
95
+ .filter(Boolean),
96
+ read,
97
+ describe(pid) {
98
+ try {
99
+ return String(readFileSyncImpl(`/proc/${pid}/cmdline`, 'utf8')).replace(/\0/g, ' ').trim();
100
+ } catch {
101
+ return '';
102
+ }
103
+ },
104
+ };
105
+ }
106
+
107
+ return {
108
+ orphansKeepParent: false,
109
+ list: () => new Promise((resolve, reject) => {
110
+ execFileImpl(
111
+ 'ps',
112
+ ['-axww', '-o', 'pid=,ppid=,stat=,lstart=,args='],
113
+ // `lstart` is parsed as a date: keep it in the one language that is.
114
+ { timeout: PS_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } },
115
+ (error, stdout) => {
116
+ if (error) reject(error);
117
+ else resolve(String(stdout || '').split(/\r?\n/).map(parsePsLine).filter(Boolean));
118
+ },
119
+ );
120
+ }),
121
+ };
122
+ }
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ // The relay's console, kept in a file.
4
+ //
5
+ // The relay writes what it does to its console: a terminal, a tmux pane, a
6
+ // service manager's journal or nothing at all, depending on how it was
7
+ // started. After an incident or a restart that output is what one wants to
8
+ // read, so the runtime copies it into `<log dir>/relay-console.log`, with a
9
+ // time stamp per line and a size limit.
10
+ //
11
+ // The copy is taken from the console's methods rather than from stdout: the
12
+ // terminal console (tty-console) redraws its prompt on stdout, and none of
13
+ // that belongs in a log.
14
+
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import { format } from 'util';
18
+
19
+ export const RELAY_CONSOLE_LOG_FILE = 'relay-console.log';
20
+ export const RELAY_CONSOLE_LOG_DISABLE_ENV = 'OAR_NO_CONSOLE_LOG';
21
+
22
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
23
+ const DEFAULT_KEEP = 3;
24
+ const LEVELS = Object.freeze(['log', 'info', 'warn', 'error', 'debug']);
25
+ const ANSI_STYLE_PATTERN = /\u001b\[[0-9;]*m/g;
26
+ // A token in a URL, whatever its value: the page is opened with `?token=`.
27
+ const TOKEN_PARAM_PATTERN = /([?&]token=)[^&\s"'<>]+/gi;
28
+ const SECRET_MIN_LENGTH = 6;
29
+
30
+ function escapeRegExp(value) {
31
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ }
33
+
34
+ /**
35
+ * Where the console log goes: the log directory the environment names, else
36
+ * `logs` under a data directory the environment names (a relay with its own
37
+ * data keeps its own log), else `logs` next to the server.
38
+ */
39
+ export function resolveRelayConsoleLogPath({ env = process.env, serverDir, pathImpl = path } = {}) {
40
+ const logDir = String(env?.COPILOT_WEB_RELAY_LOG_DIR || '').trim();
41
+ const dataDir = String(env?.COPILOT_WEB_RELAY_DATA_DIR || '').trim();
42
+ const dir = logDir
43
+ || (dataDir ? pathImpl.join(dataDir, 'logs') : pathImpl.join(String(serverDir || process.cwd()), 'logs'));
44
+ return pathImpl.join(pathImpl.resolve(dir), RELAY_CONSOLE_LOG_FILE);
45
+ }
46
+
47
+ /**
48
+ * Copies what the relay writes to its console into `logPath`. Returns null
49
+ * when it is switched off or the file cannot be opened: a log problem must
50
+ * never stop the relay.
51
+ *
52
+ * `{ logPath, setSecrets(values), rewrap(), write(level, text), uninstall() }`:
53
+ * `setSecrets` names values that are never written (the auth token, once the
54
+ * config is read), or a function that returns them, asked at every write so
55
+ * a token that changes is covered; `rewrap` puts the copy in front again
56
+ * after something else replaced the console's methods.
57
+ */
58
+ export function installRelayConsoleLog({
59
+ logPath,
60
+ env = process.env,
61
+ consoleImpl = console,
62
+ processImpl = process,
63
+ fsImpl = fs,
64
+ now = () => new Date(),
65
+ maxBytes = DEFAULT_MAX_BYTES,
66
+ keep = DEFAULT_KEEP,
67
+ } = {}) {
68
+ if (String(env?.[RELAY_CONSOLE_LOG_DISABLE_ENV] || '').trim() === '1') return null;
69
+ const target = String(logPath || '').trim();
70
+ if (!target) return null;
71
+
72
+ let fd = null;
73
+ let size = 0;
74
+ let secretSource = () => [];
75
+ let secretKey = '';
76
+ let secretPattern = null;
77
+
78
+ function open() {
79
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
80
+ fd = fsImpl.openSync(target, 'a');
81
+ try {
82
+ size = Number(fsImpl.fstatSync(fd).size) || 0;
83
+ } catch {
84
+ size = 0;
85
+ }
86
+ }
87
+
88
+ function close() {
89
+ if (fd === null) return;
90
+ try { fsImpl.closeSync(fd); } catch {}
91
+ fd = null;
92
+ }
93
+
94
+ // relay-console.log → .1 → .2 …; the oldest of `keep` is dropped.
95
+ function rotate() {
96
+ close();
97
+ try {
98
+ for (let index = keep; index >= 1; index -= 1) {
99
+ const from = index === 1 ? target : `${target}.${index - 1}`;
100
+ const to = `${target}.${index}`;
101
+ if (!fsImpl.existsSync(from)) continue;
102
+ try { fsImpl.rmSync(to, { force: true }); } catch {}
103
+ fsImpl.renameSync(from, to);
104
+ }
105
+ } catch {
106
+ // A file that cannot be moved (held open elsewhere) is written on.
107
+ }
108
+ open();
109
+ }
110
+
111
+ function currentSecretPattern() {
112
+ let values = [];
113
+ try {
114
+ values = secretSource() || [];
115
+ } catch {}
116
+ const secrets = (Array.isArray(values) ? values : [values])
117
+ .map((value) => String(value ?? '').trim())
118
+ .filter((value) => value.length >= SECRET_MIN_LENGTH);
119
+ const key = secrets.join('\n');
120
+ if (key !== secretKey) {
121
+ secretKey = key;
122
+ secretPattern = secrets.length ? new RegExp(secrets.map(escapeRegExp).join('|'), 'g') : null;
123
+ }
124
+ return secretPattern;
125
+ }
126
+
127
+ function scrub(text) {
128
+ const out = String(text).replace(ANSI_STYLE_PATTERN, '').replace(TOKEN_PARAM_PATTERN, '$1[token]');
129
+ const secrets = currentSecretPattern();
130
+ return secrets ? out.replace(secrets, '[token]') : out;
131
+ }
132
+
133
+ // Synchronous on purpose: the last lines before an exit are the ones an
134
+ // incident needs.
135
+ function write(level, text) {
136
+ if (fd === null) return;
137
+ try {
138
+ const stamp = now().toISOString();
139
+ const tag = level === 'log' ? '' : `${String(level).toUpperCase()} `;
140
+ const lines = scrub(text).split(/\r?\n/).map((line) => `${stamp} ${tag}${line}\n`).join('');
141
+ if (size > 0 && size + Buffer.byteLength(lines) > maxBytes) rotate();
142
+ if (fd === null) return;
143
+ size += fsImpl.writeSync(fd, lines);
144
+ } catch {
145
+ // A file that stopped taking writes (disk full, handle gone) is given up.
146
+ close();
147
+ }
148
+ }
149
+
150
+ try {
151
+ open();
152
+ if (size > maxBytes) rotate();
153
+ } catch {
154
+ close();
155
+ return null;
156
+ }
157
+
158
+ const wrapped = new Map();
159
+ function wrap() {
160
+ for (const level of LEVELS) {
161
+ const current = consoleImpl[level];
162
+ if (typeof current !== 'function' || current.relayConsoleLog === true) continue;
163
+ const copy = function relayConsoleCopy(...args) {
164
+ try {
165
+ write(level, format(...args));
166
+ } catch {}
167
+ return current.apply(this, args);
168
+ };
169
+ copy.relayConsoleLog = true;
170
+ copy.inner = current;
171
+ consoleImpl[level] = copy;
172
+ wrapped.set(level, copy);
173
+ }
174
+ }
175
+ wrap();
176
+
177
+ // Without a handler Node prints an uncaught error natively, past the console.
178
+ const onUncaught = (error, origin) => {
179
+ write('error', `${origin || 'uncaughtException'}: ${error?.stack || error?.message || error}`);
180
+ };
181
+ processImpl.on?.('uncaughtExceptionMonitor', onUncaught);
182
+
183
+ return {
184
+ logPath: target,
185
+ write,
186
+ rewrap: wrap,
187
+ setSecrets(values = []) {
188
+ secretSource = typeof values === 'function' ? values : () => values;
189
+ },
190
+ uninstall() {
191
+ for (const [level, copy] of wrapped) {
192
+ if (consoleImpl[level] === copy) consoleImpl[level] = copy.inner;
193
+ }
194
+ wrapped.clear();
195
+ processImpl.off?.('uncaughtExceptionMonitor', onUncaught);
196
+ close();
197
+ },
198
+ };
199
+ }
@@ -120,11 +120,16 @@ function describeRemoteTurn(action, args, caller) {
120
120
  * run, then the prompt (or the answer) it wants to send, so the user decides
121
121
  * on the actual words. `caller` is the calling session's context.
122
122
  */
123
- export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null } = {}) {
123
+ export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null, agentPrompts = 0 } = {}) {
124
124
  const name = toText(relay?.name) || 'a remote relay';
125
125
  const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
126
126
  const target = args.session ? ` (session ${shortId(args.session)})` : '';
127
- const lines = [`Allow the agent to ${verb} on relay "${name}"${target}?`];
127
+ const lines = [];
128
+ // The loop guard's card: agents that prompt each other without the user.
129
+ if (agentPrompts > 0) {
130
+ lines.push(`The agent has sent ${agentPrompts} prompts to agents on other relays since your last message. Let it go on?`);
131
+ }
132
+ lines.push(`Allow the agent to ${verb} on relay "${name}"${target}?`);
128
133
  if (action === 'send' || action === 'create_session') {
129
134
  const details = describeRemoteTurn(action, args, caller);
130
135
  if (details) lines.push(details);
@@ -155,10 +160,12 @@ export function createRemoteRelayCallerContext({
155
160
  questionTimeoutMs = DEFAULT_QUESTION_TIMEOUT_MS,
156
161
  } = {}) {
157
162
  const questionStatements = questions || createQuestionRepository(db);
158
- const getConversation = db.prepare(`SELECT id, title, preferred_relay_mode, preferred_model FROM conversations WHERE id = ?`);
163
+ const getConversation = db.prepare(`
164
+ SELECT id, title, preferred_relay_mode, preferred_model, preferred_reasoning_effort FROM conversations WHERE id = ?
165
+ `);
159
166
  const getRuntimeSession = db.prepare(`SELECT provider_type, provider_model, model FROM runtime_sessions WHERE conversation_id = ?`);
160
167
  const listProcessingRows = db.prepare(`
161
- SELECT id, kind, relay_mode, model, attempt_id, owner_sdk_session_id, timestamp, processing_at
168
+ SELECT id, kind, relay_mode, model, reasoning_effort, attempt_id, owner_sdk_session_id, timestamp, processing_at
162
169
  FROM queue
163
170
  WHERE conversation_id = ? AND status = 'processing'
164
171
  ORDER BY COALESCE(processing_at, timestamp) ASC
@@ -196,35 +203,44 @@ export function createRemoteRelayCallerContext({
196
203
  return rows.find((row) => row.id === liveId) || rows[0];
197
204
  }
198
205
 
199
- function originHops(messageId) {
200
- const origin = messageId ? repository?.getMessageOrigin?.(messageId) : null;
206
+ function messageOrigin(messageId) {
207
+ return (messageId ? repository?.getMessageOrigin?.(messageId) : null) || null;
208
+ }
209
+
210
+ function originHops(origin) {
201
211
  const hops = Number(origin?.hops);
202
212
  return Number.isInteger(hops) && hops >= 0 ? hops : 0;
203
213
  }
204
214
 
205
215
  /**
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.
216
+ * Where a turn's prompt came from (null for a human's): the origin of its
217
+ * own user message, or for a continuation row (no message of its own) that
218
+ * of the latest user message before it, the turn whose background work it
219
+ * continues.
209
220
  */
210
- function rowHops(conversationId, row) {
211
- if (toText(row.kind).toLowerCase() !== 'continuation') return originHops(row.id);
221
+ function rowOrigin(conversationId, row) {
222
+ if (toText(row.kind).toLowerCase() !== 'continuation') return messageOrigin(row.id);
212
223
  const at = toText(row.timestamp) || toText(row.processing_at);
213
224
  const message = at
214
225
  ? getLatestUserMessageAtOrBefore.get(conversationId, at)
215
226
  : getLatestUserMessage.get(conversationId);
216
- return originHops(message?.id);
227
+ return messageOrigin(message?.id);
217
228
  }
218
229
 
219
230
  /**
220
- * `{ conversationId, title, provider, model, mode, processingRowId,
221
- * attemptId, userMessageId, hops }` for the conversation making the call.
231
+ * `{ conversationId, title, provider, model, effort, mode, processingRowId,
232
+ * attemptId, userMessageId, hops, originRelayIds }` for the conversation
233
+ * making the call.
234
+ * `effort` is the reasoning effort of the live turn, else the one the
235
+ * conversation prefers ('' when neither is known).
222
236
  * `hops` is how many relays the current turn's prompt already crossed: 0
223
237
  * for a human's message. A steered message folded into the live turn is
224
238
  * part of what the agent is acting on, so the highest count among the
225
239
  * conversation's processing rows wins (a background continuation counts as
226
240
  * the turn it continues); without a turn (an off-turn call) the latest user
227
241
  * message decides.
242
+ * `originRelayIds` are the relays (their instance ids) those prompts came
243
+ * from: empty when the agent acts on the user's words alone.
228
244
  */
229
245
  function getCallerContext(conversationId) {
230
246
  const id = toText(conversationId);
@@ -232,19 +248,23 @@ export function createRemoteRelayCallerContext({
232
248
  const runtime = getRuntimeSession.get(id) || null;
233
249
  const rows = listProcessingRows.all(id);
234
250
  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);
251
+ const origins = (rows.length
252
+ ? rows.map((row) => rowOrigin(id, row))
253
+ : [messageOrigin(getLatestUserMessage.get(id)?.id)]).filter(Boolean);
254
+ const hops = Math.max(0, ...origins.map(originHops));
255
+ const originRelayIds = [...new Set(origins.map((origin) => toText(origin.relayId)))];
238
256
  return {
239
257
  conversationId: id,
240
258
  title: toText(conversation?.title),
241
259
  provider: toText(runtime?.provider_type).toLowerCase() || 'github',
242
260
  model: toText(live?.model) || toText(runtime?.provider_model) || toText(runtime?.model) || toText(conversation?.preferred_model) || '',
261
+ effort: (toText(live?.reasoning_effort) || toText(conversation?.preferred_reasoning_effort)).toLowerCase(),
243
262
  mode: normalizeMode(live?.relay_mode || conversation?.preferred_relay_mode),
244
263
  processingRowId: live?.id || null,
245
264
  attemptId: toText(live?.attempt_id) || null,
246
265
  userMessageId: live?.id || null,
247
266
  hops,
267
+ originRelayIds,
248
268
  };
249
269
  }
250
270
 
@@ -265,8 +285,11 @@ export function createRemoteRelayCallerContext({
265
285
  * denied, timed out, cancelled with the turn, or no turn to ask on (fails
266
286
  * closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
267
287
  * lives (8 h by default); `signal` withdraws the card early.
288
+ * `agentPrompts` (the loop guard) is how many prompts the agent has sent to
289
+ * other relays' agents since the user last wrote; the card then asks for
290
+ * that, in any relay mode.
268
291
  */
269
- async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
292
+ async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0 } = {}) {
270
293
  const rowId = toText(callerContext?.processingRowId);
271
294
  const row = rowId ? getQueueRow.get(rowId) : null;
272
295
  if (!row || row.status !== 'processing') {
@@ -280,10 +303,12 @@ export function createRemoteRelayCallerContext({
280
303
  const createdAt = now().toISOString();
281
304
  const questionId = String(uuid());
282
305
  const relayMode = normalizeMode(row.relay_mode);
283
- const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
306
+ const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts });
284
307
  const context = sanitizeRelayQuestionContext({
285
308
  source: REMOTE_RELAY_APPROVAL_SOURCE,
286
- rationale: `Write actions on other relays need approval in ${relayMode} mode.`,
309
+ rationale: agentPrompts > 0
310
+ ? 'Agents on different relays that keep prompting each other need your go from time to time.'
311
+ : `Write actions on other relays need approval in ${relayMode} mode.`,
287
312
  queueMessageId: row.id,
288
313
  conversationId: toText(conversationId) || row.conversation_id,
289
314
  relayMode,