@oar-sh/oar 0.9.5 → 0.9.7

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 (119) 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 +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -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
+ }
@@ -33,6 +33,8 @@ import { sanitizeRelayQuestionContext } from './relay-question-context.mjs';
33
33
 
34
34
  const LOG_PREFIX = '[remote-relays]';
35
35
  const APPROVAL_HEADER = 'Remote relay';
36
+ // The once-per-conversation card for sessions an agent starts on this relay.
37
+ const LOCAL_SESSIONS_HEADER = 'Agent sessions';
36
38
  const APPROVAL_ALLOW = 'Allow';
37
39
  const APPROVAL_DENY = 'Deny';
38
40
  const EXCERPT_MAX = 200;
@@ -115,16 +117,45 @@ function describeRemoteTurn(action, args, caller) {
115
117
  return parts.join(' · ');
116
118
  }
117
119
 
120
+ /**
121
+ * What a session an agent starts on this relay works on, for the card: the
122
+ * repository (and branch) of a Claude Cloud session, else the folder, else
123
+ * the relay's default folder.
124
+ */
125
+ function describeLocalSessionTarget(args) {
126
+ const repo = toText(args.repo).replace(/^https?:\/\/(?:www\.)?github\.com\//i, '');
127
+ if (repo) return toText(args.branch) ? `${excerpt(repo)}, branch ${excerpt(args.branch)}` : excerpt(repo);
128
+ return toText(args.cwd) ? excerpt(args.cwd) : 'the default folder';
129
+ }
130
+
118
131
  /**
119
132
  * The card text: what the agent wants to do where, how the remote turn would
120
133
  * run, then the prompt (or the answer) it wants to send, so the user decides
121
134
  * on the actual words. `caller` is the calling session's context.
135
+ *
136
+ * `localSessions` is the once-per-conversation card of the local target: the
137
+ * agent wants to start sessions on this relay itself. Allowing it covers the
138
+ * later ones of that conversation, so the card says what they cost.
122
139
  */
123
- export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null } = {}) {
140
+ export function formatRemoteRelayApprovalPrompt({ relay, action, args = {}, caller = null, agentPrompts = 0, localSessions = false } = {}) {
141
+ const local = relay?.local === true;
124
142
  const name = toText(relay?.name) || 'a remote relay';
143
+ const where = local ? 'this relay' : `relay "${name}"`;
125
144
  const verb = APPROVAL_VERBS[action] || toText(action).replace(/_/g, ' ') || 'act';
126
145
  const target = args.session ? ` (session ${shortId(args.session)})` : '';
127
- const lines = [`Allow the agent to ${verb} on relay "${name}"${target}?`];
146
+ const lines = [];
147
+ // The loop guard's card: agents that prompt each other without the user.
148
+ if (agentPrompts > 0) {
149
+ lines.push(`The agent has sent ${agentPrompts} prompts to ${local ? 'other agents' : 'agents on other relays'} since your last message. Let it go on?`);
150
+ }
151
+ if (localSessions) {
152
+ const requested = toText(args.provider).toLowerCase();
153
+ const callerProvider = toText(caller?.provider).toLowerCase();
154
+ const provider = requested || (REMOTE_RELAY_PROVIDERS.includes(callerProvider) ? callerProvider : 'github');
155
+ lines.push(`This agent wants to start sessions on this relay (${provider}, ${describeLocalSessionTarget(args)}). Sessions it starts use this relay's accounts.`);
156
+ } else {
157
+ lines.push(`Allow the agent to ${verb} on ${where}${target}?`);
158
+ }
128
159
  if (action === 'send' || action === 'create_session') {
129
160
  const details = describeRemoteTurn(action, args, caller);
130
161
  if (details) lines.push(details);
@@ -198,29 +229,34 @@ export function createRemoteRelayCallerContext({
198
229
  return rows.find((row) => row.id === liveId) || rows[0];
199
230
  }
200
231
 
201
- function originHops(messageId) {
202
- const origin = messageId ? repository?.getMessageOrigin?.(messageId) : null;
232
+ function messageOrigin(messageId) {
233
+ return (messageId ? repository?.getMessageOrigin?.(messageId) : null) || null;
234
+ }
235
+
236
+ function originHops(origin) {
203
237
  const hops = Number(origin?.hops);
204
238
  return Number.isInteger(hops) && hops >= 0 ? hops : 0;
205
239
  }
206
240
 
207
241
  /**
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.
242
+ * Where a turn's prompt came from (null for a human's): the origin of its
243
+ * own user message, or for a continuation row (no message of its own) that
244
+ * of the latest user message before it, the turn whose background work it
245
+ * continues.
211
246
  */
212
- function rowHops(conversationId, row) {
213
- if (toText(row.kind).toLowerCase() !== 'continuation') return originHops(row.id);
247
+ function rowOrigin(conversationId, row) {
248
+ if (toText(row.kind).toLowerCase() !== 'continuation') return messageOrigin(row.id);
214
249
  const at = toText(row.timestamp) || toText(row.processing_at);
215
250
  const message = at
216
251
  ? getLatestUserMessageAtOrBefore.get(conversationId, at)
217
252
  : getLatestUserMessage.get(conversationId);
218
- return originHops(message?.id);
253
+ return messageOrigin(message?.id);
219
254
  }
220
255
 
221
256
  /**
222
257
  * `{ conversationId, title, provider, model, effort, mode, processingRowId,
223
- * attemptId, userMessageId, hops }` for the conversation making the call.
258
+ * attemptId, userMessageId, hops, originRelayIds }` for the conversation
259
+ * making the call.
224
260
  * `effort` is the reasoning effort of the live turn, else the one the
225
261
  * conversation prefers ('' when neither is known).
226
262
  * `hops` is how many relays the current turn's prompt already crossed: 0
@@ -229,6 +265,8 @@ export function createRemoteRelayCallerContext({
229
265
  * conversation's processing rows wins (a background continuation counts as
230
266
  * the turn it continues); without a turn (an off-turn call) the latest user
231
267
  * message decides.
268
+ * `originRelayIds` are the relays (their instance ids) those prompts came
269
+ * from: empty when the agent acts on the user's words alone.
232
270
  */
233
271
  function getCallerContext(conversationId) {
234
272
  const id = toText(conversationId);
@@ -236,9 +274,11 @@ export function createRemoteRelayCallerContext({
236
274
  const runtime = getRuntimeSession.get(id) || null;
237
275
  const rows = listProcessingRows.all(id);
238
276
  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);
277
+ const origins = (rows.length
278
+ ? rows.map((row) => rowOrigin(id, row))
279
+ : [messageOrigin(getLatestUserMessage.get(id)?.id)]).filter(Boolean);
280
+ const hops = Math.max(0, ...origins.map(originHops));
281
+ const originRelayIds = [...new Set(origins.map((origin) => toText(origin.relayId)))];
242
282
  return {
243
283
  conversationId: id,
244
284
  title: toText(conversation?.title),
@@ -250,6 +290,7 @@ export function createRemoteRelayCallerContext({
250
290
  attemptId: toText(live?.attempt_id) || null,
251
291
  userMessageId: live?.id || null,
252
292
  hops,
293
+ originRelayIds,
253
294
  };
254
295
  }
255
296
 
@@ -270,29 +311,47 @@ export function createRemoteRelayCallerContext({
270
311
  * denied, timed out, cancelled with the turn, or no turn to ask on (fails
271
312
  * closed with REMOTE_RELAY_NO_ACTIVE_TURN). Waits as long as the question
272
313
  * lives (8 h by default); `signal` withdraws the card early.
314
+ * `agentPrompts` (the loop guard) is how many prompts the agent has sent to
315
+ * other relays' agents since the user last wrote; the card then asks for
316
+ * that, in any relay mode.
317
+ * `localSessions` makes it the card for starting sessions on this relay
318
+ * itself, asked once per conversation (the dispatcher remembers the Allow).
273
319
  */
274
- async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal } = {}) {
320
+ async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0, localSessions = false } = {}) {
275
321
  const rowId = toText(callerContext?.processingRowId);
276
322
  const row = rowId ? getQueueRow.get(rowId) : null;
277
323
  if (!row || row.status !== 'processing') {
278
324
  return {
279
325
  approved: false,
280
326
  code: REMOTE_RELAY_ERROR_CODES.noTurn,
281
- error: 'This write action needs the user\'s approval, but the conversation has no running turn to ask on.',
327
+ error: localSessions
328
+ ? 'Starting sessions on this relay needs the user\'s approval once per conversation, but the conversation has no running turn to ask on.'
329
+ : 'This write action needs the user\'s approval, but the conversation has no running turn to ask on.',
282
330
  };
283
331
  }
284
332
 
285
333
  const createdAt = now().toISOString();
286
334
  const questionId = String(uuid());
287
335
  const relayMode = normalizeMode(row.relay_mode);
288
- const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext });
336
+ const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts, localSessions });
337
+ const local = relay?.local === true;
338
+ let rationale = `Write actions on ${local ? 'other sessions of this relay' : 'other relays'} need approval in ${relayMode} mode.`;
339
+ if (agentPrompts > 0) {
340
+ rationale = local
341
+ ? 'Agents that keep prompting each other need your go from time to time.'
342
+ : 'Agents on different relays that keep prompting each other need your go from time to time.';
343
+ } else if (localSessions) {
344
+ rationale = 'Allow covers every session this conversation\'s agent starts on this relay; Deny refuses this one.';
345
+ }
346
+ // The same source for every card of this tool: it is what keeps agents
347
+ // (other relays' and this relay's own) from seeing or answering one.
289
348
  const context = sanitizeRelayQuestionContext({
290
349
  source: REMOTE_RELAY_APPROVAL_SOURCE,
291
- rationale: `Write actions on other relays need approval in ${relayMode} mode.`,
350
+ rationale,
292
351
  queueMessageId: row.id,
293
352
  conversationId: toText(conversationId) || row.conversation_id,
294
353
  relayMode,
295
- header: APPROVAL_HEADER,
354
+ header: localSessions ? LOCAL_SESSIONS_HEADER : APPROVAL_HEADER,
296
355
  }, { normalizeRelayMode: normalizeMode, defaultRelayMode });
297
356
  questionStatements.insertQuestion.run(
298
357
  questionId,
@@ -121,6 +121,15 @@ function networkError(relay, error, timeoutMs) {
121
121
  );
122
122
  }
123
123
 
124
+ /**
125
+ * The error for a relay that answered with a non-2xx status and (maybe) a JSON
126
+ * body. Shared with the in-process loopback, so a refusal of this relay's own
127
+ * routes reads exactly like a paired relay's.
128
+ */
129
+ export function remoteRelayHttpError(relay, status, body) {
130
+ return httpError(relay, status, body);
131
+ }
132
+
124
133
  function httpError(relay, status, body) {
125
134
  const remoteError = isPlainObject(body) && toText(body.error) ? toText(body.error).slice(0, REMOTE_ERROR_TEXT_MAX) : null;
126
135
  const remoteCode = isPlainObject(body) && toText(body.code) ? toText(body.code).slice(0, 100) : null;