@oar-sh/oar 0.9.6 → 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 (81) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +176 -22
  3. package/package.json +1 -1
  4. package/server/README.md +174 -7
  5. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  6. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  10. package/server/claude-worker/claude-session-process.mjs +33 -0
  11. package/server/claude-worker/claude-session-worker.mjs +6 -0
  12. package/server/db-schema.mjs +17 -0
  13. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  14. package/server/migrations/0007-claude-cloud.mjs +82 -0
  15. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  16. package/server/public/app/agent-sessions-settings.mjs +134 -0
  17. package/server/public/app/api-client.js +143 -4
  18. package/server/public/app/attachments-view.js +20 -1
  19. package/server/public/app/attribution-control.mjs +46 -0
  20. package/server/public/app/bootstrap.js +344 -31
  21. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  22. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  23. package/server/public/app/claude-cloud-ui.mjs +611 -0
  24. package/server/public/app/composer-control-state.mjs +11 -0
  25. package/server/public/app/context-menu.mjs +140 -0
  26. package/server/public/app/context-tier-options.mjs +4 -0
  27. package/server/public/app/conversation-origin-ui.js +27 -0
  28. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  29. package/server/public/app/conversation-view.js +83 -4
  30. package/server/public/app/cwd-picker.js +12 -0
  31. package/server/public/app/journal-view.js +698 -28
  32. package/server/public/app/math-dollars.mjs +89 -0
  33. package/server/public/app/model-selector-options.mjs +1 -1
  34. package/server/public/app/plan-usage-view.mjs +36 -8
  35. package/server/public/app/relay-error-ctas.mjs +16 -2
  36. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  37. package/server/public/app/router.js +49 -12
  38. package/server/public/app/settings-modal.js +33 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +51 -0
  41. package/server/public/index.html +381 -7
  42. package/server/relay-tools.md +13 -1
  43. package/server/repositories/remote-relay-repository.mjs +42 -1
  44. package/server/repositories/session-repository.mjs +47 -1
  45. package/server/routes/claude-cloud-routes.mjs +140 -0
  46. package/server/routes/git-routes.mjs +37 -0
  47. package/server/routes/messages-routes.mjs +120 -15
  48. package/server/routes/remote-relay-routes.mjs +15 -1
  49. package/server/routes/sessions-routes.mjs +502 -72
  50. package/server/server-runtime.mjs +258 -25
  51. package/server/services/claude-account-usage-service.mjs +176 -0
  52. package/server/services/claude-cloud-repo-service.mjs +408 -0
  53. package/server/services/claude-cloud-session-service.mjs +326 -0
  54. package/server/services/claude-cloud-settings-service.mjs +269 -0
  55. package/server/services/git-remote-service.mjs +115 -0
  56. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  57. package/server/services/plan-usage-claude.mjs +202 -40
  58. package/server/services/plan-usage-contract.mjs +39 -3
  59. package/server/services/plan-usage-service.mjs +82 -2
  60. package/server/services/provider-reasoning-effort.mjs +3 -0
  61. package/server/services/remote-relay-caller-context.mjs +49 -10
  62. package/server/services/remote-relay-client.mjs +9 -0
  63. package/server/services/remote-relay-dispatcher.mjs +383 -35
  64. package/server/services/remote-relay-inbound.mjs +49 -2
  65. package/server/services/remote-relay-loopback.mjs +245 -0
  66. package/server/services/remote-relay-registry-service.mjs +52 -0
  67. package/server/services/session-worker-launch-service.mjs +100 -15
  68. package/server/services/session-worker-process-service.mjs +348 -129
  69. package/server/services/session-worker-stop-service.mjs +24 -13
  70. package/server/services/session-worker-supervisor-service.mjs +56 -1
  71. package/server/services/usage-limit-pause-service.mjs +3 -1
  72. package/server/services/worker-launch-note-service.mjs +151 -0
  73. package/shared/claude-attribution.mjs +117 -0
  74. package/shared/claude-cloud/api-client.mjs +809 -0
  75. package/shared/claude-cloud/base-url.mjs +47 -0
  76. package/shared/claude-cloud/credentials.mjs +221 -0
  77. package/shared/claude-cloud/repo-url.mjs +111 -0
  78. package/shared/provider-routing.mjs +4 -2
  79. package/shared/remote-relay-contract.mjs +153 -22
  80. package/shared/remote-relay-tool-core.mjs +11 -8
  81. package/shared/steer-settle-failure.mjs +1 -0
@@ -9,9 +9,9 @@
9
9
  * while context usage describes one session's token window.
10
10
  *
11
11
  * Every provider normalizer in `plan-usage-<provider>.mjs` emits the same
12
- * `{ card: { meters, details, links } }` shape so the browser renderer stays
13
- * provider-agnostic, and so a provider that can only report *some* of its
14
- * limits degrades to fewer meters rather than to a special-cased layout.
12
+ * `{ card: { meters, details, notes, links } }` shape so the browser renderer
13
+ * stays provider-agnostic, and so a provider that can only report *some* of
14
+ * its limits degrades to fewer meters rather than to a special-cased layout.
15
15
  */
16
16
 
17
17
  export const PLAN_USAGE_VERSION = 2;
@@ -134,6 +134,16 @@ export function resolveMeterMath({
134
134
  };
135
135
  }
136
136
 
137
+ /**
138
+ * A provider's severity word as a plain lowercase token ("normal",
139
+ * "warning", …). The vocabulary is the provider's; anything that is not a
140
+ * simple word is dropped rather than shown.
141
+ */
142
+ export function toSeverity(value) {
143
+ const text = String(value ?? '').trim().toLowerCase();
144
+ return /^[a-z][a-z0-9_-]{0,23}$/.test(text) ? text : null;
145
+ }
146
+
137
147
  /**
138
148
  * @param {object} spec
139
149
  * @param {string} spec.id stable key, used by tests and DOM ids
@@ -141,6 +151,8 @@ export function resolveMeterMath({
141
151
  * @param {'credits'|'requests'|'usd'|'percent'|'tokens'} [spec.unit]
142
152
  * @param {boolean} [spec.unlimited] plan grants unlimited use of this bucket
143
153
  * @param {boolean} [spec.estimated] derived/manual rather than provider-authoritative
154
+ * @param {string} [spec.severity] the provider's own word for how close the limit is
155
+ * @param {'expiry'} [spec.resetKind] `resetAt` is when the allowance ends for good, not when it refills
144
156
  * @returns {object|null} null when the meter carries no usable signal at all
145
157
  */
146
158
  export function buildMeter({
@@ -156,6 +168,8 @@ export function buildMeter({
156
168
  unlimited = false,
157
169
  estimated = false,
158
170
  emphasis = 'primary',
171
+ severity = null,
172
+ resetKind = null,
159
173
  } = {}) {
160
174
  const meterId = toTrimmedString(id);
161
175
  const meterLabel = toTrimmedString(label);
@@ -181,10 +195,29 @@ export function buildMeter({
181
195
  remaining: math.remaining,
182
196
  utilization: math.utilization,
183
197
  resetAt: toIsoTimestamp(resetAt),
198
+ resetKind: resetKind === 'expiry' ? 'expiry' : null,
199
+ severity: toSeverity(severity),
184
200
  note: toTrimmedString(note),
185
201
  };
186
202
  }
187
203
 
204
+ /**
205
+ * A line of text on the card itself (not inside a collapsed section), with an
206
+ * optional link behind it. Same link rule as the card's footer: https only.
207
+ */
208
+ export function buildCardNote({ id, text, link = null } = {}) {
209
+ const noteId = toTrimmedString(id);
210
+ const noteText = toTrimmedString(text);
211
+ if (!noteId || !noteText) return null;
212
+ const linkLabel = toTrimmedString(link?.label);
213
+ const url = toTrimmedString(link?.url);
214
+ return {
215
+ id: noteId,
216
+ text: noteText,
217
+ link: linkLabel && url && /^https:\/\//i.test(url) ? { label: linkLabel, url } : null,
218
+ };
219
+ }
220
+
188
221
  /** A collapsible block of label/value rows shown under the meters. */
189
222
  export function buildDetailSection({ id, label, rows = [], note = null } = {}) {
190
223
  const sectionId = toTrimmedString(id);
@@ -214,6 +247,7 @@ export function buildProviderCard({
214
247
  stale = false,
215
248
  meters = [],
216
249
  details = [],
250
+ notes = [],
217
251
  links = [],
218
252
  } = {}) {
219
253
  const providerId = toTrimmedString(provider);
@@ -222,6 +256,7 @@ export function buildProviderCard({
222
256
 
223
257
  const normalizedMeters = (Array.isArray(meters) ? meters : []).filter(Boolean);
224
258
  const normalizedDetails = (Array.isArray(details) ? details : []).filter(Boolean);
259
+ const normalizedNotes = (Array.isArray(notes) ? notes : []).map((note) => buildCardNote(note || {})).filter(Boolean);
225
260
  const normalizedLinks = (Array.isArray(links) ? links : [])
226
261
  .map((link) => {
227
262
  const linkLabel = toTrimmedString(link?.label);
@@ -245,6 +280,7 @@ export function buildProviderCard({
245
280
  stale: stale === true,
246
281
  meters: normalizedMeters,
247
282
  details: normalizedDetails,
283
+ notes: normalizedNotes,
248
284
  links: normalizedLinks,
249
285
  };
250
286
  }
@@ -20,6 +20,7 @@ import {
20
20
  } from './plan-usage-contract.mjs';
21
21
  import { buildCopilotPlanCard } from './plan-usage-copilot.mjs';
22
22
  import { buildClaudePlanCard } from './plan-usage-claude.mjs';
23
+ import { CLAUDE_CLOUD_PROVIDER_ID, buildClaudeCloudPlanCard } from './plan-usage-claude-cloud.mjs';
23
24
  import {
24
25
  CURSOR_POOLS,
25
26
  applyCursorUsageDelta,
@@ -383,6 +384,59 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
383
384
  return { cycle };
384
385
  }
385
386
 
387
+ // Claude Cloud conversations keep their reported cost on the runtime
388
+ // session row (migration 0007). That table is the relay's, not this
389
+ // service's, so the statements are prepared on first use, and only once
390
+ // the table and the column are there (an older database has neither).
391
+ let claudeCloudSpendStmts = null;
392
+ function claudeCloudSpendStatements() {
393
+ if (claudeCloudSpendStmts) return claudeCloudSpendStmts;
394
+ const columns = new Set(db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column.name)));
395
+ if (!columns.has('claude_cloud_cost_usd') || !columns.has('provider_type')) return null;
396
+ claudeCloudSpendStmts = {
397
+ totals: db.prepare(`
398
+ SELECT COUNT(*) AS conversation_count, COALESCE(SUM(claude_cloud_cost_usd), 0) AS total_usd
399
+ FROM runtime_sessions
400
+ WHERE LOWER(TRIM(provider_type)) = '${CLAUDE_CLOUD_PROVIDER_ID}'
401
+ `),
402
+ byConversation: db.prepare(`
403
+ SELECT claude_cloud_cost_usd AS cost_usd
404
+ FROM runtime_sessions
405
+ WHERE conversation_id = ? AND LOWER(TRIM(provider_type)) = '${CLAUDE_CLOUD_PROVIDER_ID}'
406
+ `),
407
+ };
408
+ return claudeCloudSpendStmts;
409
+ }
410
+
411
+ /**
412
+ * What the relay's cloud conversations have cost: the sum of the latest
413
+ * cost each one reported and how many there are, plus the cost of
414
+ * `conversationId` when that is a cloud conversation (`conversation` is
415
+ * null for any other). Only conversations that still exist count: a deleted
416
+ * one takes its row with it. There are no dates on these figures, so no
417
+ * per-day or per-week sum is offered. Null when the storage is not there.
418
+ */
419
+ function readClaudeCloudSpend({ conversationId = '' } = {}) {
420
+ try {
421
+ const statements = claudeCloudSpendStatements();
422
+ if (!statements) return null;
423
+ const totals = statements.totals.get();
424
+ const id = toTrimmedString(conversationId);
425
+ const row = id ? statements.byConversation.get(id) : null;
426
+ const cost = row ? Number(row.cost_usd) : null;
427
+ return {
428
+ totalUsd: Number(totals?.total_usd) || 0,
429
+ conversationCount: Number(totals?.conversation_count) || 0,
430
+ conversation: row
431
+ ? { costUsd: row.cost_usd !== null && Number.isFinite(cost) ? cost : null }
432
+ : null,
433
+ };
434
+ } catch (error) {
435
+ dbg('claude cloud spend read failed', error?.message || String(error));
436
+ return null;
437
+ }
438
+ }
439
+
386
440
  /**
387
441
  * Assemble the full multi-provider report. Every provider is independent:
388
442
  * one failing source produces an unavailable card instead of failing the
@@ -392,8 +446,17 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
392
446
  copilotSummary = null,
393
447
  copilotError = null,
394
448
  copilotStale = false,
449
+ // When the quota answer is an earlier one served again (the modal asked
450
+ // for another provider live), the time it was really read.
451
+ copilotCapturedAt = null,
395
452
  copilotBilling = null,
396
453
  claudeConfigured = true,
454
+ // Live account usage (claude-account-usage-service.mjs), or null: it is
455
+ // only ever read while Claude Cloud is switched on.
456
+ claudeAccount = null,
457
+ claudeCloudConfigured = false,
458
+ // The conversation the modal was opened from, for its own cloud cost.
459
+ conversationId = '',
397
460
  cursorConfigured = true,
398
461
  cursorAllowances = null,
399
462
  cursorBilling = null,
@@ -404,6 +467,9 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
404
467
  } = {}) {
405
468
  const generatedAt = nowIso();
406
469
  const claudeSnapshot = readSnapshot('claude');
470
+ // What the cloud workers post to /api/claude-plan-usage is kept under its
471
+ // own key, so the Claude card stays what the Claude workers wrote.
472
+ const claudeCloudSnapshot = claudeCloudConfigured === true ? readSnapshot(CLAUDE_CLOUD_PROVIDER_ID) : null;
407
473
  const allowances = normalizeCursorAllowanceSettings(cursorAllowances || {});
408
474
  const cursorCycle = readCursorCycleTotals({ resetDay: allowances.resetDay });
409
475
  const grokSettings = normalizeGrokAllowanceSettings(grokAllowances || {});
@@ -421,7 +487,7 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
421
487
  billing: copilotBilling,
422
488
  workerUsage: copilotWorkerSnapshot?.payload || null,
423
489
  error: copilotError,
424
- capturedAt: generatedAt,
490
+ capturedAt: copilotCapturedAt || generatedAt,
425
491
  stale: copilotStale === true,
426
492
  }),
427
493
  buildClaudePlanCard({
@@ -431,9 +497,22 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
431
497
  message: claudeSnapshot?.error || null,
432
498
  // A stored snapshot is by definition from an earlier turn; the SDK's
433
499
  // usage control call only works while a session transport is open, and
434
- // the relay never opens a billable turn just to refresh this.
500
+ // the relay never opens a billable turn just to refresh this. Live
501
+ // account usage, where there is any, takes the meters over.
435
502
  stale: !!claudeSnapshot?.payload,
503
+ account: claudeAccount,
436
504
  }),
505
+ // Opt-in like Grok (no card while the provider is off), and placed next
506
+ // to the Claude card because both are the same account.
507
+ claudeCloudConfigured === true
508
+ ? buildClaudeCloudPlanCard({
509
+ account: claudeAccount,
510
+ spend: readClaudeCloudSpend({ conversationId }),
511
+ usage: claudeCloudSnapshot?.payload || null,
512
+ capturedAt: claudeCloudSnapshot?.capturedAt || null,
513
+ configured: true,
514
+ })
515
+ : null,
437
516
  buildCursorPlanCard({
438
517
  totals: cursorCycle.totals,
439
518
  allowances,
@@ -481,6 +560,7 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
481
560
  recordGrokUsageReport,
482
561
  readGrokCycleTotals,
483
562
  resetGrokAccounting,
563
+ readClaudeCloudSpend,
484
564
  buildReport,
485
565
  pools: CURSOR_POOLS,
486
566
  };
@@ -12,6 +12,7 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
12
12
  export const CLAUDE_ULTRACODE_EFFORT = 'ultracode';
13
13
  export const DEFAULT_CLAUDE_REASONING_EFFORTS = Object.freeze(['none', 'low', 'medium', 'high', 'xhigh', 'max', CLAUDE_ULTRACODE_EFFORT]);
14
14
  export const DEFAULT_GROK_REASONING_EFFORTS = Object.freeze(['none', 'low', 'medium', 'high']);
15
+ export const CLAUDE_CLOUD_REASONING_EFFORTS = Object.freeze(['none']);
15
16
 
16
17
  // Ultracode requires an xhigh-capable model (the SDK's stated gate), so the
17
18
  // tier is derived from a model's discovered efforts rather than discovery —
@@ -54,6 +55,8 @@ export function supportedReasoningEffortsForProviderModel({
54
55
  if (provider === 'grok') {
55
56
  return effortsForModel(grokSettings?.effortsByModel, model) || [...DEFAULT_GROK_REASONING_EFFORTS];
56
57
  }
58
+ // A cloud session takes no effort choice: the only tier is the default one.
59
+ if (provider === 'claude-cloud') return [...CLAUDE_CLOUD_REASONING_EFFORTS];
57
60
  if (provider === 'openai' || provider === 'openai-image') {
58
61
  const efforts = openAIReasoningEffortsForModel(model);
59
62
  return Array.isArray(efforts) && efforts.length ? efforts.map(normalizeEffort).filter(Boolean) : null;
@@ -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,21 +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, agentPrompts = 0 } = {}) {
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
146
  const lines = [];
128
147
  // The loop guard's card: agents that prompt each other without the user.
129
148
  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?`);
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}?`);
131
158
  }
132
- lines.push(`Allow the agent to ${verb} on relay "${name}"${target}?`);
133
159
  if (action === 'send' || action === 'create_session') {
134
160
  const details = describeRemoteTurn(action, args, caller);
135
161
  if (details) lines.push(details);
@@ -288,31 +314,44 @@ export function createRemoteRelayCallerContext({
288
314
  * `agentPrompts` (the loop guard) is how many prompts the agent has sent to
289
315
  * other relays' agents since the user last wrote; the card then asks for
290
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).
291
319
  */
292
- async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0 } = {}) {
320
+ async function requestApproval({ conversationId, callerContext, relay, action, args = {}, signal, agentPrompts = 0, localSessions = false } = {}) {
293
321
  const rowId = toText(callerContext?.processingRowId);
294
322
  const row = rowId ? getQueueRow.get(rowId) : null;
295
323
  if (!row || row.status !== 'processing') {
296
324
  return {
297
325
  approved: false,
298
326
  code: REMOTE_RELAY_ERROR_CODES.noTurn,
299
- 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.',
300
330
  };
301
331
  }
302
332
 
303
333
  const createdAt = now().toISOString();
304
334
  const questionId = String(uuid());
305
335
  const relayMode = normalizeMode(row.relay_mode);
306
- const prompt = formatRemoteRelayApprovalPrompt({ relay, action, args, caller: callerContext, agentPrompts });
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.
307
348
  const context = sanitizeRelayQuestionContext({
308
349
  source: REMOTE_RELAY_APPROVAL_SOURCE,
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.`,
350
+ rationale,
312
351
  queueMessageId: row.id,
313
352
  conversationId: toText(conversationId) || row.conversation_id,
314
353
  relayMode,
315
- header: APPROVAL_HEADER,
354
+ header: localSessions ? LOCAL_SESSIONS_HEADER : APPROVAL_HEADER,
316
355
  }, { normalizeRelayMode: normalizeMode, defaultRelayMode });
317
356
  questionStatements.insertQuestion.run(
318
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;