@oar-sh/oar 0.9.5 → 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 (63) 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 +168 -0
  4. package/README.md +20 -4
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-session-process.mjs +118 -1
  7. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  9. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  10. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  11. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  12. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  13. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  15. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  16. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  17. package/server/db-schema.mjs +7 -0
  18. package/server/grok-worker/acp-client.mjs +68 -12
  19. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  21. package/server/public/app/api-client.js +17 -2
  22. package/server/public/app/bootstrap.js +3 -0
  23. package/server/public/app/composer-control-state.mjs +4 -3
  24. package/server/public/app/conversation-view.js +95 -4
  25. package/server/public/app/journal-view.js +6 -0
  26. package/server/public/app/markdown-strikethrough.mjs +31 -0
  27. package/server/public/app/relay-board-inline.mjs +70 -0
  28. package/server/public/app/relay-board-view.js +157 -61
  29. package/server/public/app/relay-error-ctas.mjs +12 -3
  30. package/server/public/app/socket-handlers.js +9 -0
  31. package/server/public/app/store.js +4 -1
  32. package/server/public/app/subagent-fold.mjs +61 -0
  33. package/server/public/app/usage-limit-ui.js +225 -0
  34. package/server/public/index.html +90 -1
  35. package/server/repositories/message-repository.mjs +10 -1
  36. package/server/repositories/question-repository.mjs +1 -0
  37. package/server/repositories/remote-relay-repository.mjs +30 -0
  38. package/server/routes/messages-routes.mjs +85 -14
  39. package/server/routes/relay-board-routes.mjs +56 -2
  40. package/server/routes/sessions-routes.mjs +36 -0
  41. package/server/server-runtime.mjs +33 -2
  42. package/server/services/preview-proxy-server.mjs +64 -36
  43. package/server/services/process-list-service.mjs +122 -0
  44. package/server/services/relay-console-log.mjs +199 -0
  45. package/server/services/remote-relay-caller-context.mjs +37 -17
  46. package/server/services/remote-relay-dispatcher.mjs +72 -8
  47. package/server/services/remote-relay-inbound.mjs +35 -0
  48. package/server/services/session-worker-process-service.mjs +85 -32
  49. package/server/services/session-worker-registry-service.mjs +1 -1
  50. package/server/services/session-worker-supervisor-service.mjs +33 -1
  51. package/server/services/usage-limit-pause-service.mjs +441 -0
  52. package/shared/ask-user-bridge.mjs +14 -39
  53. package/shared/claude-usage-limit.mjs +257 -0
  54. package/shared/control-poller.mjs +69 -20
  55. package/shared/failure-note-text.mjs +38 -0
  56. package/shared/question-wait.mjs +108 -0
  57. package/shared/remote-relay-contract.mjs +16 -2
  58. package/shared/steer-settle-failure.mjs +16 -0
  59. package/shared/steer-settle-markers.mjs +8 -0
  60. package/shared/worker-runtime/process-tree.mjs +483 -0
  61. package/shared/worker-runtime/relay-errors.mjs +31 -0
  62. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  63. package/shared/worker-runtime/turn-liveness.mjs +249 -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);
@@ -198,29 +203,34 @@ export function createRemoteRelayCallerContext({
198
203
  return rows.find((row) => row.id === liveId) || rows[0];
199
204
  }
200
205
 
201
- function originHops(messageId) {
202
- 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) {
203
211
  const hops = Number(origin?.hops);
204
212
  return Number.isInteger(hops) && hops >= 0 ? hops : 0;
205
213
  }
206
214
 
207
215
  /**
208
- * A turn's hops: those of its own user message, or for a continuation row
209
- * (no message of its own) those of the latest user message before it, the
210
- * 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.
211
220
  */
212
- function rowHops(conversationId, row) {
213
- 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);
214
223
  const at = toText(row.timestamp) || toText(row.processing_at);
215
224
  const message = at
216
225
  ? getLatestUserMessageAtOrBefore.get(conversationId, at)
217
226
  : getLatestUserMessage.get(conversationId);
218
- return originHops(message?.id);
227
+ return messageOrigin(message?.id);
219
228
  }
220
229
 
221
230
  /**
222
231
  * `{ conversationId, title, provider, model, effort, mode, processingRowId,
223
- * attemptId, userMessageId, hops }` for the conversation making the call.
232
+ * attemptId, userMessageId, hops, originRelayIds }` for the conversation
233
+ * making the call.
224
234
  * `effort` is the reasoning effort of the live turn, else the one the
225
235
  * conversation prefers ('' when neither is known).
226
236
  * `hops` is how many relays the current turn's prompt already crossed: 0
@@ -229,6 +239,8 @@ export function createRemoteRelayCallerContext({
229
239
  * conversation's processing rows wins (a background continuation counts as
230
240
  * the turn it continues); without a turn (an off-turn call) the latest user
231
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.
232
244
  */
233
245
  function getCallerContext(conversationId) {
234
246
  const id = toText(conversationId);
@@ -236,9 +248,11 @@ export function createRemoteRelayCallerContext({
236
248
  const runtime = getRuntimeSession.get(id) || null;
237
249
  const rows = listProcessingRows.all(id);
238
250
  const live = liveTurnRow(id, rows);
239
- const hops = rows.length
240
- ? Math.max(...rows.map((row) => rowHops(id, row)))
241
- : 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)))];
242
256
  return {
243
257
  conversationId: id,
244
258
  title: toText(conversation?.title),
@@ -250,6 +264,7 @@ export function createRemoteRelayCallerContext({
250
264
  attemptId: toText(live?.attempt_id) || null,
251
265
  userMessageId: live?.id || null,
252
266
  hops,
267
+ originRelayIds,
253
268
  };
254
269
  }
255
270
 
@@ -270,8 +285,11 @@ export function createRemoteRelayCallerContext({
270
285
  * denied, timed out, cancelled with the turn, or no turn to ask on (fails
271
286
  * closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
272
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.
273
291
  */
274
- async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
292
+ async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0 } = {}) {
275
293
  const rowId = toText(callerContext?.processingRowId);
276
294
  const row = rowId ? getQueueRow.get(rowId) : null;
277
295
  if (!row || row.status !== 'processing') {
@@ -285,10 +303,12 @@ export function createRemoteRelayCallerContext({
285
303
  const createdAt = now().toISOString();
286
304
  const questionId = String(uuid());
287
305
  const relayMode = normalizeMode(row.relay_mode);
288
- const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
306
+ const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts });
289
307
  const context = sanitizeRelayQuestionContext({
290
308
  source: REMOTE_RELAY_APPROVAL_SOURCE,
291
- 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.`,
292
312
  queueMessageId: row.id,
293
313
  conversationId: toText(conversationId) || row.conversation_id,
294
314
  relayMode,
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { randomUUID as nodeRandomUUID } from 'crypto';
4
4
 
5
+ import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
5
6
  import {
6
7
  REMOTE_RELAY_APPROVAL_MODES,
7
8
  REMOTE_RELAY_EFFORT_PATTERN,
@@ -9,6 +10,7 @@ import {
9
10
  REMOTE_RELAY_LIMITS,
10
11
  REMOTE_RELAY_PROVIDERS,
11
12
  isRemoteRelayApprovalQuestion,
13
+ isRemoteRelayPromptAction,
12
14
  isRemoteRelayWriteAction,
13
15
  normalizeRemoteRelayOrigin,
14
16
  remoteConversationUrl,
@@ -90,7 +92,6 @@ const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
90
92
  // marker counts only there: a normal reply that quotes a relay error further
91
93
  // down is a reply, not a failure.
92
94
  const FAILURE_TEXT_PATTERNS = Object.freeze([
93
- /^(?:(?!\n[ \t]*\r?\n)[\s\S]){0,600}?\berror code:\s*relay\.[a-z0-9-]+\.(?=\s|$)/i,
94
95
  /^relay recovery limit reached after \d+ attempts/i,
95
96
  /^relay timeout after \d+ attempts/i,
96
97
  ]);
@@ -184,7 +185,7 @@ function compactQuery(query = {}) {
184
185
 
185
186
  function looksLikeFailureText(text) {
186
187
  const value = toText(text);
187
- return FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
188
+ return isFailureNoteText(value) || FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
188
189
  }
189
190
 
190
191
  function normalizeProvider(value) {
@@ -534,6 +535,10 @@ export function createRemoteRelayDispatcher({
534
535
  const inflightByConversation = new Map();
535
536
  const callTimesByConversation = new Map();
536
537
  const writeTimesByConversation = new Map();
538
+ // The loop guard: prompts sent to other relays' agents per conversation,
539
+ // counted from the user's latest own message. Kept in memory: a restart
540
+ // ends a loop of agents by itself.
541
+ const agentPromptsByConversation = new Map();
537
542
 
538
543
  // ─── Bookkeeping ───────────────────────────────────────────────────────────
539
544
 
@@ -556,6 +561,31 @@ export function createRemoteRelayDispatcher({
556
561
  }
557
562
  }
558
563
 
564
+ /**
565
+ * Why a relay is open to a conversation: 'user' (the user mentioned it),
566
+ * 'contact' (an agent on that relay wrote to the conversation, so it may be
567
+ * answered), or null (locked).
568
+ */
569
+ function relayAccess(conversationId, relay) {
570
+ if (isUnlocked(conversationId, relay?.id)) return 'user';
571
+ try {
572
+ if (toText(relay?.relayId) && repository?.hasContactFrom?.(conversationId, relay.relayId)) return 'contact';
573
+ } catch {}
574
+ return null;
575
+ }
576
+
577
+ function agentPrompts(conversationId) {
578
+ let since = null;
579
+ try {
580
+ since = repository?.latestHumanMessageId?.(conversationId) || null;
581
+ } catch {}
582
+ const kept = agentPromptsByConversation.get(conversationId);
583
+ if (kept && kept.since === since) return kept;
584
+ const fresh = { since, count: 0 };
585
+ agentPromptsByConversation.set(conversationId, fresh);
586
+ return fresh;
587
+ }
588
+
559
589
  function adjustInflight(conversationId, delta) {
560
590
  const next = inflight(conversationId) + delta;
561
591
  if (next > 0) inflightByConversation.set(conversationId, next);
@@ -1594,7 +1624,7 @@ export function createRemoteRelayDispatcher({
1594
1624
  online: toText(relay.lastStatus).toLowerCase() === 'online',
1595
1625
  version: relay.version || null,
1596
1626
  permission: relay.permission,
1597
- unlocked: isUnlocked(conversationId, relay.id),
1627
+ unlocked: relayAccess(conversationId, relay) !== null,
1598
1628
  }));
1599
1629
  const unlocked = listed.filter((relay) => relay.unlocked).length;
1600
1630
  return {
@@ -1604,7 +1634,7 @@ export function createRemoteRelayDispatcher({
1604
1634
  summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
1605
1635
  self: { name: (await registry.selfName?.()) || null },
1606
1636
  relays: listed,
1607
- hint: 'Mention @name in the chat to unlock a relay.',
1637
+ hint: 'The user unlocks a relay by mentioning @name in the chat; a relay whose agent wrote to this conversation is open as well.',
1608
1638
  },
1609
1639
  };
1610
1640
  }
@@ -1635,7 +1665,8 @@ export function createRemoteRelayDispatcher({
1635
1665
  return result;
1636
1666
  };
1637
1667
 
1638
- if (!isUnlocked(conversationId, relay.id)) {
1668
+ const access = relayAccess(conversationId, relay);
1669
+ if (!access) {
1639
1670
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
1640
1671
  `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.`,
1641
1672
  { relay: relay.name }));
@@ -1649,9 +1680,16 @@ export function createRemoteRelayDispatcher({
1649
1680
  const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
1650
1681
  caller.mode = toText(caller.mode).toLowerCase();
1651
1682
  const hops = nonNegativeInt(caller.hops) + 1;
1652
- if (hops > REMOTE_RELAY_LIMITS.hopLimit) {
1683
+ // The hop limit stops a prompt from travelling on to a third relay. A
1684
+ // relay the user unlocked here stays in reach whatever prompts arrive,
1685
+ // the relay the prompts came from may always be answered, and reading
1686
+ // carries nothing on.
1687
+ const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
1688
+ const answering = !!toText(relay.relayId) && origins.length > 0
1689
+ && origins.every((id) => id === toText(relay.relayId));
1690
+ if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit) {
1653
1691
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
1654
- `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.`,
1692
+ `This turn acts on a prompt from another relay's agent (hop ${hops - 1}); passing it on to relay "${relay.name}" would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops. The user can allow it by mentioning @${relay.name} in this conversation.`,
1655
1693
  { relay: relay.name }));
1656
1694
  }
1657
1695
 
@@ -1664,7 +1702,32 @@ export function createRemoteRelayDispatcher({
1664
1702
 
1665
1703
  adjustInflight(conversationId, 1);
1666
1704
  try {
1667
- if (write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
1705
+ const prompting = isRemoteRelayPromptAction(action);
1706
+ let asked = false;
1707
+ if (prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
1708
+ // Agents that prompt each other can go on without the user for good:
1709
+ // from time to time the user is asked, in any relay mode.
1710
+ const decision = await requestApproval({
1711
+ conversationId,
1712
+ callerContext: caller,
1713
+ relay: relayTarget(relay),
1714
+ action,
1715
+ args,
1716
+ summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1717
+ signal,
1718
+ agentPrompts: agentPrompts(conversationId).count,
1719
+ });
1720
+ const approved = decision === true || decision?.approved === true;
1721
+ if (!approved) {
1722
+ const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
1723
+ const error = toText(decision?.error)
1724
+ || `The agent has sent ${agentPrompts(conversationId).count} prompts to other relays' agents since the user last wrote, and the user did not allow more for now.`;
1725
+ return finish(failure(403, code, error, { relay: relay.name }));
1726
+ }
1727
+ agentPrompts(conversationId).count = 0;
1728
+ asked = true;
1729
+ }
1730
+ if (!asked && write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
1668
1731
  const decision = await requestApproval({
1669
1732
  conversationId,
1670
1733
  callerContext: caller,
@@ -1685,6 +1748,7 @@ export function createRemoteRelayDispatcher({
1685
1748
 
1686
1749
  const ctx = { relay, caller, hops, conversationId, signal };
1687
1750
  const body = await ACTIONS[action](ctx, args);
1751
+ if (prompting) agentPrompts(conversationId).count += 1;
1688
1752
  const { summary, ...rest } = body;
1689
1753
  return finish({
1690
1754
  status: 200,
@@ -161,6 +161,41 @@ export function formatRemoteRelayMentionHint(relays = []) {
161
161
  return `<system_reminder>The user mentioned ${subject}. The ${REMOTE_RELAY_TOOL_NAME} tool can list, read, prompt and create sessions there.</system_reminder>`;
162
162
  }
163
163
 
164
+ /**
165
+ * The paired relay a remote prompt came from: `{ relayId, name }` (`relayId`
166
+ * is the local registry id), or null when the sender is not paired here or
167
+ * names no relay id.
168
+ */
169
+ export function findOriginRemoteRelay(inbound, origin) {
170
+ const instance = String(origin?.relayId || '').trim();
171
+ if (!inbound || typeof inbound.listRelays !== 'function' || !instance) return null;
172
+ try {
173
+ const listed = inbound.listRelays();
174
+ const relay = (Array.isArray(listed) ? listed : [])
175
+ .find((entry) => String(entry?.relayId || '').trim() === instance);
176
+ return relay ? { relayId: String(relay.id || ''), name: String(relay.name || relay.id || '') } : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * The reminder the agent gets with a prompt another relay's agent sent: that
184
+ * relay is open to this conversation, and where the sender's session is. ''
185
+ * when the sender is not a paired relay. Like the mention hint, it rides in
186
+ * the queued prompt only.
187
+ */
188
+ export function formatRemoteRelayOriginHint(relay, origin) {
189
+ const name = hintText(relay?.name);
190
+ if (!name) return '';
191
+ const session = hintText(origin?.conversationId, 80);
192
+ const where = session ? `relay "${name}", session "${session}"` : `relay "${name}"`;
193
+ return `<system_reminder>This prompt came from an agent on the paired OAR relay "${name}". `
194
+ + 'Your reply to it is handed back to that agent by itself. '
195
+ + `For more than the reply (reading its session, a question, a later report) the ${REMOTE_RELAY_TOOL_NAME} tool is open for that relay in this conversation: ${where}. `
196
+ + 'A prompt you send there while its turn is still running is steered into that turn.</system_reminder>';
197
+ }
198
+
164
199
  /** The queued prompt with the mention hint after the user's text. */
165
200
  export function withRemoteRelayMentionHint(promptText, hint) {
166
201
  const text = String(promptText ?? '');