@oar-sh/oar 0.9.24 → 0.9.26

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 (92) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +284 -0
  3. package/README.md +12 -11
  4. package/package.json +5 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +8 -4
  7. package/server/claude-worker/claude-session-process.mjs +4 -0
  8. package/server/claude-worker/claude-turn-publisher.mjs +25 -11
  9. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  10. package/server/copilot-worker/copilot-prompt-context.mjs +2 -1
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  12. package/server/cursor-worker/cursor-turn-runner.mjs +3 -1
  13. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  14. package/server/db-schema.mjs +19 -0
  15. package/server/grok-worker/grok-turn-runner.mjs +3 -1
  16. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  17. package/server/migrations/0039-media-index.mjs +167 -0
  18. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  19. package/server/public/app/api-client.js +58 -0
  20. package/server/public/app/ask-user-view.js +2 -1
  21. package/server/public/app/attachments-view.js +66 -9
  22. package/server/public/app/bootstrap.js +85 -25
  23. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  24. package/server/public/app/context-media-tab.js +143 -0
  25. package/server/public/app/context-usage-view.mjs +2 -2
  26. package/server/public/app/conversation-view.js +90 -5
  27. package/server/public/app/image-composer-ui.js +518 -0
  28. package/server/public/app/image-turn-placeholder.mjs +233 -0
  29. package/server/public/app/journal-view.js +12 -2
  30. package/server/public/app/live-activity-cap.mjs +3 -3
  31. package/server/public/app/markdown-math.mjs +471 -0
  32. package/server/public/app/media-gallery-model.mjs +202 -0
  33. package/server/public/app/media-gallery-ui.js +524 -0
  34. package/server/public/app/media-view.js +79 -0
  35. package/server/public/app/openai-image-composer.mjs +492 -0
  36. package/server/public/app/openai-image-cost-ui.js +426 -0
  37. package/server/public/app/openai-image-spend.mjs +443 -0
  38. package/server/public/app/plan-usage-view.mjs +38 -6
  39. package/server/public/app/pwa-install.js +25 -4
  40. package/server/public/app/relay-error-ctas.mjs +5 -0
  41. package/server/public/app/relay-prompt-sanitizer.mjs +3 -0
  42. package/server/public/app/relay-restart-ui.js +2 -2
  43. package/server/public/app/remote-relays-settings.js +33 -6
  44. package/server/public/app/router.js +49 -237
  45. package/server/public/app/session-history-ui.js +2 -0
  46. package/server/public/app/settings-modal.js +4 -0
  47. package/server/public/app/settings-tabs.js +10 -1
  48. package/server/public/app/socket-handlers.js +26 -0
  49. package/server/public/app/store.js +2 -0
  50. package/server/public/app/stream-view-preference.mjs +1 -1
  51. package/server/public/app/tab-strip-fade.mjs +1 -1
  52. package/server/public/app/thoughts-preference.mjs +1 -1
  53. package/server/public/app/tmux-inspector-view.js +29 -16
  54. package/server/public/app/viewer-gallery.mjs +23 -1
  55. package/server/public/index.html +517 -123
  56. package/server/relay-tools.md +1 -0
  57. package/server/repositories/image-conversation-repository.mjs +69 -0
  58. package/server/repositories/media-repository.mjs +305 -0
  59. package/server/routes/media-routes.mjs +361 -0
  60. package/server/routes/messages-routes.mjs +1410 -360
  61. package/server/routes/relay-board-routes.mjs +46 -1
  62. package/server/routes/remote-relay-routes.mjs +1 -1
  63. package/server/routes/sessions-routes.mjs +361 -14
  64. package/server/server-runtime.mjs +21 -0
  65. package/server/services/database-file.mjs +6 -4
  66. package/server/services/generated-image-cleanup-service.mjs +2 -1
  67. package/server/services/generated-image-files.mjs +110 -0
  68. package/server/services/image-input-paths.mjs +389 -0
  69. package/server/services/image-operation-service.mjs +6 -2
  70. package/server/services/media-extract.mjs +653 -0
  71. package/server/services/media-index-service.mjs +221 -0
  72. package/server/services/openai-image-preview-turns.mjs +330 -0
  73. package/server/services/openai-image-spend-service.mjs +516 -0
  74. package/server/services/openai-image-stream.mjs +170 -0
  75. package/server/services/plan-usage-openai-image.mjs +148 -0
  76. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  77. package/server/services/relay-restart-queue.mjs +1 -1
  78. package/server/services/remote-relay-caller-context.mjs +6 -0
  79. package/server/services/remote-relay-dispatcher.mjs +409 -23
  80. package/server/services/remote-relay-registry-service.mjs +10 -2
  81. package/server/services/session-worker-stop-service.mjs +50 -18
  82. package/server/services/token-usage-service.mjs +15 -0
  83. package/shared/image-header.mjs +90 -0
  84. package/shared/math-instructions.mjs +30 -0
  85. package/shared/openai-image-options.mjs +299 -0
  86. package/shared/openai-image-presets.mjs +175 -0
  87. package/shared/openai-image-previews.mjs +40 -0
  88. package/shared/openai-image-prices.mjs +291 -0
  89. package/shared/openai-image-spend.mjs +90 -0
  90. package/shared/remote-relay-contract.mjs +126 -4
  91. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
  92. package/server/public/app/math-dollars.mjs +0 -89
@@ -30,6 +30,22 @@ import {
30
30
  validateRemoteRelayToolInput,
31
31
  withRemotePromptHeader,
32
32
  } from '../../shared/remote-relay-contract.mjs';
33
+ import {
34
+ IMAGE_INPUT_REFUSED_CODE,
35
+ OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE,
36
+ OPENAI_IMAGE_NO_MODEL_CODE,
37
+ formatOpenAIImageSummary,
38
+ isOpenAIImageModelId,
39
+ openAIImageModelNeedsOpenAIText,
40
+ openAIImageNoModelText,
41
+ pickOpenAIImageDefaultModel,
42
+ } from '../../shared/openai-image-options.mjs';
43
+ import {
44
+ OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
45
+ OPENAI_IMAGE_PRESETS_PATH,
46
+ OPENAI_IMAGE_USAGE_PATH,
47
+ formatOpenAIImageUsd,
48
+ } from '../../shared/openai-image-spend.mjs';
33
49
  import { schemaFields } from '../../shared/question-schema.mjs';
34
50
  import { PLAN_ONLY_ERROR_CODE, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
35
51
  import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
@@ -110,6 +126,8 @@ const PROGRESS_ACTIVITY_COUNT = 5;
110
126
  const ACTIVITY_LINE_MAX = 300;
111
127
  const ACTIVITY_LINES_PER_MESSAGE = 20;
112
128
  const REPLY_TEXT_MAX = REMOTE_RELAY_LIMITS.perMessageChars * 2;
129
+ // The images one message lists (an image turn makes at most four).
130
+ const REPLY_IMAGES_MAX = 8;
113
131
  // A message that would only fit as a stub is dropped instead (read_session).
114
132
  const MIN_TRIMMED_MESSAGE_CHARS = 200;
115
133
  const TITLE_MAX = 80;
@@ -316,12 +334,17 @@ function catalogModelsLabelled(catalog, label) {
316
334
  .map(([model]) => model);
317
335
  }
318
336
 
319
- /** Copilot's catalogue: every model of /api/models no other provider claims alone. */
337
+ /**
338
+ * Copilot's catalogue: every model of /api/models no other provider claims
339
+ * alone, without image ids (a relay that predates their filter lists them;
340
+ * the Copilot runtime cannot serve them).
341
+ */
320
342
  function catalogGithubModels(catalog) {
321
343
  const models = catalog?.models || {};
322
344
  const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
323
345
  const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
324
346
  return catalogModels.filter((model) => {
347
+ if (isOpenAIImageModelId(model)) return false;
325
348
  const providers = catalogProvidersFor(providersByModel, model);
326
349
  return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
327
350
  });
@@ -427,6 +450,61 @@ function isTransientRemoteError(error) {
427
450
  // POST /api/message answers a replayed messageId with this (409).
428
451
  const DUPLICATE_MESSAGE_ID_CODE = 'DUPLICATE_MESSAGE_ID';
429
452
 
453
+ // The actions that carry images or image options to the target.
454
+ const IMAGE_INPUT_ACTIONS = new Set(['send', 'create_session', 'update_session']);
455
+
456
+ function imageInputRefusalText(error) {
457
+ const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
458
+ if (toText(body?.code) !== IMAGE_INPUT_REFUSED_CODE) return null;
459
+ return toText(body.error).replace(/[.\s]+$/, '') || 'refused';
460
+ }
461
+
462
+ /** The target's words when its OpenAI image cap refused a send (409), or null for any other error. */
463
+ function imageCapRefusalText(error) {
464
+ const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
465
+ if (toText(body?.code) !== OPENAI_IMAGE_DAILY_CAP_REACHED_CODE) return null;
466
+ return toText(body.error) || 'the daily cap of OpenAI image spend is reached';
467
+ }
468
+
469
+ /** A route the target does not have: an OAR from before it. */
470
+ function isMissingRemoteRoute(error) {
471
+ return Number(error?.status) === 404 || toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound;
472
+ }
473
+
474
+ /**
475
+ * The spend state of a target's OpenAI images as the agent reads it (the
476
+ * `usage` action's entry), or null for a state that says nothing.
477
+ */
478
+ function openAIImageSpendEntry(state) {
479
+ if (!isPlainObject(state)) return null;
480
+ const number = (value) => {
481
+ const parsed = Number(value);
482
+ return value !== null && value !== undefined && value !== '' && Number.isFinite(parsed) ? parsed : null;
483
+ };
484
+ const used = Math.round(((number(state.spentUsd) ?? 0) + (number(state.reservedUsd) ?? 0)) * 100) / 100;
485
+ const cap = number(state.capUsd);
486
+ const capUsd = cap !== null && cap > 0 ? cap : null;
487
+ const remaining = capUsd === null ? null : Math.max(0, Math.round((capUsd - used) * 100) / 100);
488
+ const allowedToday = state.allowedToday === true;
489
+ const reached = capUsd !== null && state.reached === true;
490
+ const text = capUsd === null
491
+ ? `today ${formatOpenAIImageUsd(used)}, no daily cap`
492
+ : `today ${formatOpenAIImageUsd(used)} of the daily cap of ${formatOpenAIImageUsd(capUsd)} (${Math.round((used / capUsd) * 100)} %), ${formatOpenAIImageUsd(remaining)} left`
493
+ + (reached ? (allowedToday ? '; past the cap, allowed for today by the owner' : '; new image turns are refused until tomorrow (relay time) or until the owner allows more for today') : '');
494
+ return {
495
+ label: 'OpenAI images',
496
+ day: toText(state.day) || null,
497
+ usedUsd: used,
498
+ capUsd,
499
+ remainingUsd: remaining,
500
+ ...(number(state.monthUsd) !== null ? { monthUsd: number(state.monthUsd) } : {}),
501
+ warning: state.warning === true,
502
+ reached,
503
+ allowedToday,
504
+ text,
505
+ };
506
+ }
507
+
430
508
  function isOwnDuplicateMessage(error, messageId) {
431
509
  const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
432
510
  if (Number(error?.status) !== 409 || toText(body?.code) !== DUPLICATE_MESSAGE_ID_CODE) return false;
@@ -586,13 +664,61 @@ export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundW
586
664
  };
587
665
  }
588
666
 
667
+ function positiveIntOrNull(value) {
668
+ const number = Number(value);
669
+ return Number.isInteger(number) && number > 0 ? number : null;
670
+ }
671
+
672
+ function generatedImageAttachments(row) {
673
+ return (Array.isArray(row?.attachments) ? row.attachments : []).filter((entry) => isPlainObject(entry?.generatedImage));
674
+ }
675
+
676
+ /**
677
+ * The images an assistant message made, as the agent reads them: their files
678
+ * on the target relay (which a later send there can attach), size and type.
679
+ * A relay that does not give the path (an older one, a file gone) lists none.
680
+ */
681
+ function replyImagesOf(row) {
682
+ const images = [];
683
+ for (const attachment of generatedImageAttachments(row)) {
684
+ const path = toText(attachment.generatedImage.path);
685
+ if (!path) continue;
686
+ images.push({
687
+ path,
688
+ width: positiveIntOrNull(attachment.width),
689
+ height: positiveIntOrNull(attachment.height),
690
+ mime: toText(attachment.type) || null,
691
+ });
692
+ if (images.length >= REPLY_IMAGES_MAX) break;
693
+ }
694
+ return images;
695
+ }
696
+
697
+ /**
698
+ * An assistant message's text, or for an image turn whose model wrote none
699
+ * the line on what it made ("1 image · 1536x1024 · gpt-image-2"): newer
700
+ * relays store that line as the text, older messages have nothing.
701
+ */
702
+ function assistantText(row, model = '') {
703
+ const text = String(row?.text ?? '');
704
+ if (text.trim()) return text;
705
+ const generated = generatedImageAttachments(row);
706
+ if (!generated.length) return text;
707
+ return formatOpenAIImageSummary({
708
+ images: generated.map((attachment) => ({ width: positiveIntOrNull(attachment.width), height: positiveIntOrNull(attachment.height) })),
709
+ model: toText(row?.model) || toText(model),
710
+ });
711
+ }
712
+
589
713
  function replyOf(row, extra = {}) {
590
- const clipped = clipMiddle(toText(row?.text), REPLY_TEXT_MAX);
714
+ const clipped = clipMiddle(toText(assistantText(row)), REPLY_TEXT_MAX);
715
+ const images = replyImagesOf(row);
591
716
  return {
592
717
  text: clipped.text,
593
718
  model: toText(row?.model) || null,
594
719
  messageId: toText(row?.id) || null,
595
720
  ...(clipped.truncated ? { truncated: true } : {}),
721
+ ...(images.length ? { images } : {}),
596
722
  ...extra,
597
723
  };
598
724
  }
@@ -1453,24 +1579,43 @@ export function createRemoteRelayDispatcher({
1453
1579
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
1454
1580
  `Relay "${ctx.relay.name}" reports only the Copilot quota in an older shape; it needs an update for plan usage per provider.`);
1455
1581
  }
1456
- const limit = await call(ctx, 'GET', '/api/usage-limit').catch(() => null);
1457
- const providers = cards.slice(0, 12).map(usageCard).filter(Boolean);
1582
+ const [limit, imageSpend] = await Promise.all([
1583
+ call(ctx, 'GET', '/api/usage-limit').catch(() => null),
1584
+ // OpenAI images against the relay's daily cap; an OAR from before the
1585
+ // cap has no route for it, and then no entry.
1586
+ call(ctx, 'GET', OPENAI_IMAGE_USAGE_PATH)
1587
+ .then((state) => ({ entry: openAIImageSpendEntry(state) }))
1588
+ .catch((error) => ({ entry: null, failed: !isMissingRemoteRoute(error) })),
1589
+ ]);
1590
+ const openaiImages = imageSpend.entry;
1591
+ // The entry says it all: the report's own OpenAI images card would be a second copy.
1592
+ const providers = cards.slice(0, 12).map(usageCard).filter(Boolean)
1593
+ .filter((card) => !(openaiImages && card.provider === 'openai'));
1458
1594
  const set = providers.filter((card) => !['not-configured', 'unavailable'].includes(card.status)).map((card) => card.provider);
1459
1595
  const usageLimit = limit?.ok === true ? usageLimitState(limit) : null;
1596
+ const notes = [
1597
+ usageLimit ? '' : 'The usage-limit state was not available there.',
1598
+ imageSpend.failed ? 'The OpenAI image spend was not available there.' : '',
1599
+ ].filter(Boolean);
1460
1600
  return {
1461
- summary: `usage → ${ctx.relay.name}: ${set.join(', ') || 'no provider'} reported${usageLimit?.pauses?.length ? `, ${usageLimit.pauses.length} paused at a usage limit` : ''}`,
1601
+ summary: `usage → ${ctx.relay.name}: ${set.join(', ') || 'no provider'} reported${usageLimit?.pauses?.length ? `, ${usageLimit.pauses.length} paused at a usage limit` : ''}${openaiImages ? `; OpenAI images ${openaiImages.text}` : ''}`,
1462
1602
  providers,
1603
+ ...(openaiImages ? { openaiImages } : {}),
1463
1604
  usageLimit,
1464
- ...(usageLimit ? {} : { note: 'The usage-limit state was not available there.' }),
1605
+ ...(notes.length ? { note: notes.join(' ') } : {}),
1465
1606
  };
1466
1607
  }
1467
1608
 
1468
1609
  async function relayInfo(ctx) {
1469
- const [identity, catalog, groups] = await Promise.all([
1610
+ const [identity, catalog, groups, presets] = await Promise.all([
1470
1611
  call(ctx, 'GET', '/api/relay/identity').catch(() => null),
1471
1612
  loadCatalog(ctx),
1472
1613
  relayGroups(ctx),
1614
+ // The image presets a send or create_session may name; an OAR from
1615
+ // before them has no route, and then no list.
1616
+ call(ctx, 'GET', OPENAI_IMAGE_PRESETS_PATH).catch(() => null),
1473
1617
  ]);
1618
+ const imagePresets = Array.isArray(presets?.presets) ? relayImagePresets(presets.presets) : null;
1474
1619
  const status = catalog.status;
1475
1620
  const providers = buildProviders(catalog);
1476
1621
  const remoteRelays = isPlainObject(identity?.remoteRelays) ? identity.remoteRelays : null;
@@ -1488,6 +1633,8 @@ export function createRemoteRelayDispatcher({
1488
1633
  // The inbound switch is about other relays' agents; this relay's own are
1489
1634
  // let in by the agent-sessions setting, which is on or this would not run.
1490
1635
  acceptsAgentPrompts: ctx.relay.local ? true : (remoteRelays ? remoteRelays.inbound !== false : null),
1636
+ // Whether send and create_session may carry images and image there.
1637
+ imageInputs: ctx.relay.local ? true : remoteRelays?.imageInputs === true,
1491
1638
  ...(ctx.relay.local ? {
1492
1639
  sessionRules: `create_session: at most ${REMOTE_RELAY_LIMITS.localActiveSessions} sessions you started may work at the same time; a session an agent created cannot create sessions unless the owner allowed it for that session (mayCreateSessions); sessions run on this relay's accounts. update_session: sessions you started freely, any other (the owner's, another agent's, your creator's) after the owner's Allow (once per conversation).`,
1493
1640
  } : {}),
@@ -1501,9 +1648,24 @@ export function createRemoteRelayDispatcher({
1501
1648
  defaultRelayMode: toText(status.defaultRelayMode) || null,
1502
1649
  // The session groups a create_session there may name (`group`).
1503
1650
  ...groups,
1651
+ ...(imagePresets ? { imagePresets } : {}),
1504
1652
  };
1505
1653
  }
1506
1654
 
1655
+ /** A target's image presets as relay_info lists them: name, the values, builtIn. */
1656
+ function relayImagePresets(list) {
1657
+ return list.slice(0, 40).filter(isPlainObject).map((preset) => {
1658
+ const name = toText(preset.name).slice(0, 40);
1659
+ if (!name) return null;
1660
+ const values = {};
1661
+ for (const key of ['size', 'quality', 'count', 'output_format', 'background']) {
1662
+ const value = preset[key];
1663
+ if (key === 'count' ? Number.isInteger(value) : (typeof value === 'string' && value.trim())) values[key] = key === 'count' ? value : value.trim().slice(0, 40);
1664
+ }
1665
+ return { name, ...values, builtIn: preset.builtIn === true };
1666
+ }).filter(Boolean);
1667
+ }
1668
+
1507
1669
  /**
1508
1670
  * The target relay's session workers, from its /api/status: `working`, the
1509
1671
  * conversations whose worker is starting or running a turn, and
@@ -1724,7 +1886,8 @@ export function createRemoteRelayDispatcher({
1724
1886
  };
1725
1887
  }
1726
1888
 
1727
- function messageText(row) {
1889
+ function messageText(row, sessionModel = '') {
1890
+ if (row?.role === 'assistant') return assistantText(row, sessionModel);
1728
1891
  const text = String(row?.text ?? '');
1729
1892
  // The header line is ours (or another relay's): the badge fields say the
1730
1893
  // same, so the agent reads the prompt itself.
@@ -1737,7 +1900,7 @@ export function createRemoteRelayDispatcher({
1737
1900
  * returns a 9000-character reply whole, ten messages in the default 12000
1738
1901
  * still get 4000 each. A trimmed message keeps its head and its tail.
1739
1902
  */
1740
- function shapeMessages(rows, { maxChars, includeActivity }) {
1903
+ function shapeMessages(rows, { maxChars, includeActivity, sessionModel = '' }) {
1741
1904
  const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
1742
1905
  const out = [];
1743
1906
  const perMessageMax = Math.max(REMOTE_RELAY_LIMITS.perMessageChars, Math.floor(maxChars / Math.max(1, list.length)));
@@ -1745,7 +1908,7 @@ export function createRemoteRelayDispatcher({
1745
1908
  let omitted = 0;
1746
1909
  for (let index = list.length - 1; index >= 0; index -= 1) {
1747
1910
  const row = list[index];
1748
- const perMessage = clipMiddle(messageText(row), perMessageMax);
1911
+ const perMessage = clipMiddle(messageText(row, sessionModel), perMessageMax);
1749
1912
  let text = perMessage.text;
1750
1913
  let truncated = perMessage.truncated;
1751
1914
  if (text.length > budget) {
@@ -1762,6 +1925,8 @@ export function createRemoteRelayDispatcher({
1762
1925
  // The newest lines, as the progress path reads them: the relay keeps the
1763
1926
  // newest per thread now, so the first ones are not the start anyway.
1764
1927
  const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities, { fromEnd: true }) : [];
1928
+ // Like the tool lines, outside the text budget: a few short entries.
1929
+ const images = role === 'assistant' ? replyImagesOf(row) : [];
1765
1930
  out.unshift({
1766
1931
  id: toText(row.id),
1767
1932
  role,
@@ -1772,6 +1937,7 @@ export function createRemoteRelayDispatcher({
1772
1937
  ...(via ? { via } : {}),
1773
1938
  text,
1774
1939
  ...(truncated ? { truncated: true } : {}),
1940
+ ...(images.length ? { images } : {}),
1775
1941
  ...(activities.length ? { activities } : {}),
1776
1942
  });
1777
1943
  if (budget <= 0 && index > 0) {
@@ -1800,9 +1966,12 @@ export function createRemoteRelayDispatcher({
1800
1966
  sessionWorkers(ctx),
1801
1967
  ]);
1802
1968
  const worker = workers.workerFor(conversationId);
1969
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1970
+ const sessionModel = toText(runtime.providerModel) || toText(conversation?.preferredModel) || toText(runtime.model) || null;
1803
1971
  const { messages, omitted } = shapeMessages(conversation?.messages, {
1804
1972
  maxChars: args.max_chars,
1805
1973
  includeActivity: args.include_activity,
1974
+ sessionModel,
1806
1975
  });
1807
1976
  let olderCursor = null;
1808
1977
  if (omitted > 0 && messages.length) {
@@ -1813,7 +1982,6 @@ export function createRemoteRelayDispatcher({
1813
1982
  beforeTimestamp: toText(conversation.pageInfo.olderCursor.beforeTimestamp),
1814
1983
  });
1815
1984
  }
1816
- const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1817
1985
  const via = toText(conversation?.origin?.relayName);
1818
1986
  const inFlight = progressSnapshot(conversation?.inFlight);
1819
1987
  // Its session group, with the other members and what each is doing.
@@ -1839,7 +2007,7 @@ export function createRemoteRelayDispatcher({
1839
2007
  id: conversationId,
1840
2008
  title: toText(conversation?.title),
1841
2009
  provider: toText(runtime.providerType) || 'github',
1842
- model: toText(runtime.providerModel) || toText(conversation?.preferredModel) || toText(runtime.model) || null,
2010
+ model: sessionModel,
1843
2011
  mode: toText(conversation?.preferredRelayMode) || null,
1844
2012
  cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1845
2013
  archived: conversation?.archived === true,
@@ -1983,7 +2151,7 @@ export function createRemoteRelayDispatcher({
1983
2151
  * When the replay fails too, nobody knows; the error carries the session
1984
2152
  * and message id so the agent checks with wait instead of sending again.
1985
2153
  */
1986
- async function postPrompt(ctx, { session, text, origin, model, mode, effort, billing = '' }) {
2154
+ async function postPrompt(ctx, { session, text, origin, model, mode, effort, billing = '', imagePaths = null, image = null }) {
1987
2155
  const messageId = String(randomUUID());
1988
2156
  const body = {
1989
2157
  conversationId: session,
@@ -1993,6 +2161,9 @@ export function createRemoteRelayDispatcher({
1993
2161
  ...(model ? { model } : {}),
1994
2162
  ...(mode ? { relayMode: mode } : {}),
1995
2163
  ...(effort ? { reasoningEffort: effort } : {}),
2164
+ // Read on the target: the paths are its own files.
2165
+ ...(Array.isArray(imagePaths) && imagePaths.length ? { imagePaths } : {}),
2166
+ ...(isPlainObject(image) && Object.keys(image).length ? { image } : {}),
1996
2167
  };
1997
2168
  // A Claude session moved to another billing with this message: one call,
1998
2169
  // which switches and enqueues together on the target, or does neither.
@@ -2003,12 +2174,12 @@ export function createRemoteRelayDispatcher({
2003
2174
  try {
2004
2175
  sent = await post();
2005
2176
  } catch (error) {
2006
- if (!isTransientRemoteError(error)) throw error;
2177
+ if (!isTransientRemoteError(error)) throw imageInputFailure(ctx, error, session) || imageCapFailure(ctx, error, session) || error;
2007
2178
  try {
2008
2179
  sent = await post();
2009
2180
  } catch (retryError) {
2010
2181
  if (isOwnDuplicateMessage(retryError, messageId)) return { duplicate: false, messageId };
2011
- if (!isTransientRemoteError(retryError)) throw retryError;
2182
+ if (!isTransientRemoteError(retryError)) throw imageInputFailure(ctx, retryError, session) || imageCapFailure(ctx, retryError, session) || retryError;
2012
2183
  throw new DispatchFailure(502, toText(retryError.code) || REMOTE_RELAY_ERROR_CODES.offline,
2013
2184
  `${toText(retryError.message) || `Relay "${ctx.relay.name}" did not answer`}; the prompt may or may not have been queued.`,
2014
2185
  {
@@ -2028,9 +2199,107 @@ export function createRemoteRelayDispatcher({
2028
2199
  duplicate: false,
2029
2200
  messageId: toText(sent?.messageId) || messageId,
2030
2201
  effort: normalizeEffort(sent?.selectedReasoningEffort),
2202
+ // How many of the images the target attached; null when it does not say.
2203
+ imageInputs: Number.isInteger(sent?.imageInputs) && sent.imageInputs >= 0 ? sent.imageInputs : null,
2031
2204
  };
2032
2205
  }
2033
2206
 
2207
+ /** The target's refusal of the images or image options, as the agent reads it, or null for any other error. */
2208
+ function imageInputFailure(ctx, error, session) {
2209
+ const reason = imageInputRefusalText(error);
2210
+ if (reason === null) return null;
2211
+ const refusal = new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2212
+ `Relay "${ctx.relay.name}" refused the images or image options: ${reason}. Nothing was sent.`,
2213
+ { session, remoteCode: IMAGE_INPUT_REFUSED_CODE });
2214
+ refusal.imageInputReason = reason;
2215
+ return refusal;
2216
+ }
2217
+
2218
+ /**
2219
+ * The target's OpenAI image cap refused the send (409): its words, which
2220
+ * name the spend and the cap, and that nothing was sent; or null for any
2221
+ * other error.
2222
+ */
2223
+ function imageCapFailure(ctx, error, session) {
2224
+ const words = imageCapRefusalText(error);
2225
+ if (words === null) return null;
2226
+ const where = ctx.relay.local ? 'This relay' : `Relay "${ctx.relay.name}"`;
2227
+ return new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
2228
+ `${where} refused the image turn: ${words.replace(/[.\s]+$/, '')}.${/nothing was sent/i.test(words) ? '' : ' Nothing was sent.'}`,
2229
+ {
2230
+ session,
2231
+ remoteCode: OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
2232
+ note: 'Ask the user to allow more for today (Settings → Providers → OpenAI on that relay) or wait until tomorrow (relay time); do not send it again before.',
2233
+ });
2234
+ }
2235
+
2236
+ /**
2237
+ * Before create_session starts an OpenAI image session: refused when the
2238
+ * target's daily cap of image spend is reached and the owner did not allow
2239
+ * today, so no session is left behind. A target without the route (an OAR
2240
+ * from before the cap) has no cap; any other failure to read it is no
2241
+ * reason to stop, since the target checks the cap again at the send.
2242
+ */
2243
+ async function refuseAtImageCap(ctx) {
2244
+ let state = null;
2245
+ try {
2246
+ // A target that hangs delays the session by 5 s at most; a timeout
2247
+ // carries on like any other error (the send-time check decides).
2248
+ state = await call(ctx, 'GET', OPENAI_IMAGE_USAGE_PATH, { timeoutMs: 5000 });
2249
+ } catch {
2250
+ return;
2251
+ }
2252
+ const entry = openAIImageSpendEntry(state);
2253
+ if (!entry?.reached || entry.allowedToday) return;
2254
+ const where = ctx.relay.local ? 'This relay' : `Relay "${ctx.relay.name}"`;
2255
+ throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.providerUnavailable,
2256
+ `${where} has reached its daily cap of OpenAI image spend: ${formatOpenAIImageUsd(entry.usedUsd)} today of ${formatOpenAIImageUsd(entry.capUsd)}. No session was created.`,
2257
+ {
2258
+ remoteCode: OPENAI_IMAGE_DAILY_CAP_REACHED_CODE,
2259
+ note: 'Ask the user to allow more for today (Settings → Providers → OpenAI on that relay) or wait until tomorrow (relay time).',
2260
+ });
2261
+ }
2262
+
2263
+ /**
2264
+ * A target that takes the images answers how many it attached. An older one
2265
+ * that slipped in after the check (a downgrade) drops them silently and runs
2266
+ * the turn without: the prompt is queued by then, so the agent is told.
2267
+ */
2268
+ function imageEchoNote(ctx, sent, imagePaths) {
2269
+ const wanted = Array.isArray(imagePaths) ? imagePaths.length : 0;
2270
+ if (!wanted || sent?.duplicate) return null;
2271
+ const where = ctx.relay.local ? 'This relay' : `Relay "${ctx.relay.name}"`;
2272
+ if (sent.imageInputs === null || sent.imageInputs === undefined) {
2273
+ return `${where} did not confirm that it attached the ${wanted === 1 ? 'image' : `${wanted} images`}; the turn may run without them.`;
2274
+ }
2275
+ if (sent.imageInputs < wanted) {
2276
+ return `${where} attached ${sent.imageInputs} of ${wanted} images; the turn may run without them.`;
2277
+ }
2278
+ return null;
2279
+ }
2280
+
2281
+ /**
2282
+ * images and image need a target that takes them: an older OAR passes the
2283
+ * body fields over and runs (and bills) the turn without them. Its identity
2284
+ * is read fresh: the stored copy keeps only the fields this relay knew of
2285
+ * when it last probed. A relay without the identity route is older still.
2286
+ */
2287
+ async function refuseWithoutImageInputs(ctx, action) {
2288
+ let identity = null;
2289
+ try {
2290
+ identity = await call(ctx, 'GET', '/api/relay/identity');
2291
+ } catch (error) {
2292
+ if (!(Number(error?.status) === 404 || toText(error?.code) === REMOTE_RELAY_ERROR_CODES.notFound)) throw error;
2293
+ }
2294
+ if (isPlainObject(identity?.remoteRelays) && identity.remoteRelays.imageInputs === true) return;
2295
+ const nothing = action === 'update_session'
2296
+ ? 'nothing was changed'
2297
+ : (action === 'create_session' ? 'nothing was created or sent' : 'nothing was sent');
2298
+ throw new DispatchFailure(409, REMOTE_RELAY_ERROR_CODES.unsupported,
2299
+ `Relay "${ctx.relay.name}" runs an older OAR that cannot take images or image options: ${nothing}.`,
2300
+ { note: 'Leave images and image out to work there without them, or ask the user to update that relay.' });
2301
+ }
2302
+
2034
2303
  /**
2035
2304
  * POST /api/conversation/:id/billing with a message. A relay older than the
2036
2305
  * switch answers 404 without a code: nothing was switched or sent. Its
@@ -2212,7 +2481,16 @@ export function createRemoteRelayDispatcher({
2212
2481
  const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
2213
2482
  const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
2214
2483
  const origin = await buildOrigin(ctx);
2215
- const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode, billing: switchTo }, {
2484
+ const { sent, choice } = await postPromptWithEffort(ctx, {
2485
+ session,
2486
+ text: args.text,
2487
+ origin,
2488
+ model,
2489
+ mode,
2490
+ billing: switchTo,
2491
+ imagePaths: args.images,
2492
+ image: args.image,
2493
+ }, {
2216
2494
  effort: requested || own,
2217
2495
  source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
2218
2496
  note: null,
@@ -2220,6 +2498,8 @@ export function createRemoteRelayDispatcher({
2220
2498
  const billingNote = switchTo
2221
2499
  ? `Session ${session} now runs on ${switchTo === 'claude-api' ? 'the API key' : 'the subscription'}, from this message on (a running turn finishes on the old billing).`
2222
2500
  : '';
2501
+ const imageNote = imageEchoNote(ctx, sent, args.images);
2502
+ const note = [effortResult(choice).note, billingNote, imageNote].filter(Boolean).join(' ');
2223
2503
  return deliverAndWait('send', ctx, {
2224
2504
  session,
2225
2505
  sent,
@@ -2229,7 +2509,7 @@ export function createRemoteRelayDispatcher({
2229
2509
  ...(billing && REMOTE_RELAY_SEND_PROVIDERS.includes(provider) ? { provider: switchTo || provider } : {}),
2230
2510
  ...(switchTo ? { billingSwitched: true } : {}),
2231
2511
  ...effortResult(choice),
2232
- ...(billingNote ? { note: [effortResult(choice).note, billingNote].filter(Boolean).join(' ') } : {}),
2512
+ ...(note ? { note } : {}),
2233
2513
  },
2234
2514
  });
2235
2515
  }
@@ -2243,6 +2523,13 @@ export function createRemoteRelayDispatcher({
2243
2523
  * model left unchecked for want of a list included.
2244
2524
  */
2245
2525
  function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [], updating = false }) {
2526
+ // Refused here before the provider's list is looked at, which lacks the
2527
+ // id and would answer "unknown model" without the way out.
2528
+ if (provider === 'github' && isOpenAIImageModelId(requested)) {
2529
+ throw new DispatchFailure(400, OPENAI_IMAGE_MODEL_NEEDS_OPENAI_CODE,
2530
+ `${openAIImageModelNeedsOpenAIText(requested)} ${creating ? 'No session was created.' : (updating ? 'Nothing was changed.' : 'Nothing was sent.')}`,
2531
+ { provider, model: requested, note: 'Pass provider "openai" with this model (relay_info lists the openai models), or a Copilot model that writes text.' });
2532
+ }
2246
2533
  const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
2247
2534
  const offered = catalog ? offeredModelList(catalog, provider) : null;
2248
2535
  const check = checkRequestedProviderModel({
@@ -2337,13 +2624,27 @@ export function createRemoteRelayDispatcher({
2337
2624
  note = settled.note;
2338
2625
  variantEffort = normalizeEffort(settled.effort);
2339
2626
  }
2627
+ // An OpenAI session started with images or image options is an image
2628
+ // session: it mirrors this session's model only when that makes images,
2629
+ // else it takes the image default of the target's OpenAI list (the pick
2630
+ // its New Chat makes). A list without an image model is refused below.
2631
+ const wantsImages = (Array.isArray(args.images) && args.images.length > 0) || isPlainObject(args.image);
2632
+ const imageSession = provider === 'openai' && wantsImages;
2340
2633
  // A cloud session without a requested model gets the Claude Cloud tab's
2341
2634
  // default, as New Chat does: the bootstrap picks it, nothing is mirrored.
2342
2635
  if (!model && cloud) {
2343
2636
  modelSource = 'remote default';
2344
2637
  } else if (!model) {
2345
- model = matchOfferedModel(ctx.caller.model, entry.models);
2346
- modelSource = model ? 'same as this session' : '';
2638
+ const mirrored = matchOfferedModel(ctx.caller.model, entry.models);
2639
+ const imageDefault = imageSession && !isOpenAIImageModelId(mirrored) ? pickOpenAIImageDefaultModel(entry.models) : '';
2640
+ // Never a chat model for an image session: it would make no images.
2641
+ if (imageSession && !imageDefault && !isOpenAIImageModelId(mirrored)) {
2642
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
2643
+ `${openAIImageNoModelText(`relay "${ctx.relay.name}"`)} No session was created.`,
2644
+ { remoteCode: OPENAI_IMAGE_NO_MODEL_CODE });
2645
+ }
2646
+ model = imageDefault || mirrored;
2647
+ modelSource = imageDefault ? 'remote default' : (model ? 'same as this session' : '');
2347
2648
  }
2348
2649
  if (!model && !cloud) {
2349
2650
  model = entry.defaultModel || entry.models[0] || '';
@@ -2352,6 +2653,16 @@ export function createRemoteRelayDispatcher({
2352
2653
  note = `${provider} on relay "${ctx.relay.name}" does not offer ${ctx.caller.model}; the session uses its default model ${model || '(none)'}.`;
2353
2654
  }
2354
2655
  }
2656
+ // Images and image options go to an image session only. Settled here, the
2657
+ // provider (this session's when none is named) and the model are known:
2658
+ // refused before the bootstrap, so no session is left behind. Only what
2659
+ // needs the new session's folder (the paths) is the target's to refuse.
2660
+ if (wantsImages && (provider !== 'openai' || !isOpenAIImageModelId(model))) {
2661
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2662
+ `images and image go to an OpenAI image session (provider "openai" with a gpt-image or dall-e model); this one would run ${provider}${model ? ` ${model}` : ''}. `
2663
+ + 'Pass provider "openai" and leave model out (the relay\'s image default) or name an image model relay_info lists. No session was created.',
2664
+ { remoteCode: IMAGE_INPUT_REFUSED_CODE });
2665
+ }
2355
2666
  // planOnly starts the session locked in plan mode: with no mode named it
2356
2667
  // starts in plan; another mode named is refused before anything is made.
2357
2668
  const planOnly = args.planOnly === true || toText(args.planOnly).toLowerCase() === 'true';
@@ -2361,6 +2672,9 @@ export function createRemoteRelayDispatcher({
2361
2672
  }
2362
2673
  const mode = args.mode || (planOnly ? 'plan' : ctx.caller.mode) || toText(catalog.status.defaultRelayMode) || '';
2363
2674
  const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
2675
+ // An image session would make its first images at once: a target whose
2676
+ // daily cap of image spend is reached refuses before it is created.
2677
+ if (provider === 'openai' && isOpenAIImageModelId(model)) await refuseAtImageCap(ctx);
2364
2678
  const origin = await buildOrigin(ctx);
2365
2679
 
2366
2680
  // The effort mirrors the caller like the model does: this session's when
@@ -2449,7 +2763,15 @@ export function createRemoteRelayDispatcher({
2449
2763
  }
2450
2764
  let sent;
2451
2765
  try {
2452
- const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
2766
+ const posted = await postPromptWithEffort(ctx, {
2767
+ session,
2768
+ text: args.text,
2769
+ origin,
2770
+ model: boundModel,
2771
+ mode,
2772
+ imagePaths: args.images,
2773
+ image: args.image,
2774
+ }, choice);
2453
2775
  sent = posted.sent;
2454
2776
  if (!cloud || choice.effort) choice = posted.choice;
2455
2777
  // From here the new session's queued turn counts toward the cap itself.
@@ -2457,6 +2779,16 @@ export function createRemoteRelayDispatcher({
2457
2779
  } catch (error) {
2458
2780
  // The session exists by now: say so, or the agent starts a second one.
2459
2781
  const remoteCode = toText(error?.remoteBody?.code);
2782
+ if (error instanceof DispatchFailure && error.imageInputReason) {
2783
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2784
+ `Relay "${ctx.relay.name}" refused the images or image options of the first prompt: ${error.imageInputReason}. The session ${session} was created, but the prompt was not queued.`,
2785
+ {
2786
+ session,
2787
+ remoteCode: IMAGE_INPUT_REFUSED_CODE,
2788
+ openUrl: remoteConversationUrl(ctx.relay.url, session) || null,
2789
+ note: 'Use send with this session and other images or image options (or none); do not create another session.',
2790
+ });
2791
+ }
2460
2792
  if (!choice.effort || !isEffortRejection(error)) {
2461
2793
  // The remote's own 4xx is passed on, anything else is a 502, as
2462
2794
  // mapRemoteError answers them.
@@ -2488,6 +2820,8 @@ export function createRemoteRelayDispatcher({
2488
2820
  note: 'Use send with this session and an effort it takes (or none); do not create another session.',
2489
2821
  });
2490
2822
  }
2823
+ const imageNote = imageEchoNote(ctx, sent, args.images);
2824
+ const effortFields = effortResult(choice);
2491
2825
  const result = await deliverAndWait('create_session', ctx, {
2492
2826
  session,
2493
2827
  sent,
@@ -2501,7 +2835,8 @@ export function createRemoteRelayDispatcher({
2501
2835
  ...(cloud ? { repo: toText(bootstrap?.cloud?.repoUrl) || args.repo, branch: toText(bootstrap?.cloud?.branch) || args.branch || null } : {}),
2502
2836
  ...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
2503
2837
  ...(note ? { modelNote: note } : {}),
2504
- ...effortResult(choice),
2838
+ ...effortFields,
2839
+ ...(imageNote ? { note: [effortFields.note, imageNote].filter(Boolean).join(' ') } : {}),
2505
2840
  // The session group it went into, as list_sessions shows it.
2506
2841
  group,
2507
2842
  ...(groupWarning ? { groupWarning } : {}),
@@ -2824,6 +3159,21 @@ export function createRemoteRelayDispatcher({
2824
3159
  };
2825
3160
  }
2826
3161
 
3162
+ /**
3163
+ * The changed settings for the summary line. Names only, but the image
3164
+ * defaults (an object) with what they were set to:
3165
+ * `image {size 1536x1024, quality low}`.
3166
+ */
3167
+ function changedText(changed, settings) {
3168
+ return changed.map((key) => {
3169
+ if (key !== 'image' || !isPlainObject(settings?.image)) return key;
3170
+ const parts = Object.entries(settings.image)
3171
+ .filter(([, value]) => toText(value))
3172
+ .map(([name, value]) => `${name} ${clipLine(value, 40)}`);
3173
+ return parts.length ? `image {${parts.join(', ')}}` : key;
3174
+ }).join(', ');
3175
+ }
3176
+
2827
3177
  /** The `settings` of an answer as the agent reads it: the target's, the group in list_sessions' shape. */
2828
3178
  function settingsResult(settings) {
2829
3179
  if (!isPlainObject(settings)) return null;
@@ -2851,7 +3201,7 @@ export function createRemoteRelayDispatcher({
2851
3201
  notes.push('This mode lasts until the next send that names another mode or comes from a caller in another mode: a send without mode passes the caller\'s own mode.');
2852
3202
  }
2853
3203
  return {
2854
- summary: `update_session → ${where}: ${changed.join(', ') || 'nothing changed'}`,
3204
+ summary: `update_session → ${where}: ${changedText(changed, plan.settings) || 'nothing changed'}`,
2855
3205
  session: plan.session,
2856
3206
  settings: settingsResult(answer?.settings),
2857
3207
  changed,
@@ -3777,6 +4127,24 @@ export function createRemoteRelayDispatcher({
3777
4127
  return null;
3778
4128
  }
3779
4129
 
4130
+ /**
4131
+ * Is `session` on `relay` the session whose agent created this conversation
4132
+ * (its origin, which an agent of this relay never sets for a paired one)?
4133
+ */
4134
+ function sendsToCreator(conversationId, relay, session) {
4135
+ if (relay.local === true || !toText(relay.relayId) || !toText(session)) return false;
4136
+ let origin = null;
4137
+ try {
4138
+ origin = repository.getConversationOrigin?.(conversationId) || null;
4139
+ } catch {
4140
+ return false;
4141
+ }
4142
+ return !!origin && origin.local !== true
4143
+ && toText(origin.relayId) === toText(relay.relayId)
4144
+ && !!toText(origin.conversationId)
4145
+ && toText(origin.conversationId) === toText(session);
4146
+ }
4147
+
3780
4148
  async function runRelayAction({ conversationId, action, args, req, signal, waitNote }) {
3781
4149
  const target = await resolveTarget(args.relay);
3782
4150
  if (!target.relay) return target.failure;
@@ -3839,7 +4207,11 @@ export function createRemoteRelayDispatcher({
3839
4207
  const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
3840
4208
  const answering = !!toText(relay.relayId) && origins.length > 0
3841
4209
  && origins.every((id) => id === toText(relay.relayId));
3842
- if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit) {
4210
+ // A session another relay's agent created may always send to the session
4211
+ // that created it, whatever else its turn acts on (a prompt of a session
4212
+ // on this relay, say): its reports go back the way its work came.
4213
+ if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit
4214
+ && !(action === 'send' && sendsToCreator(conversationId, relay, args.session))) {
3843
4215
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
3844
4216
  `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.`,
3845
4217
  { relay: relay.name }));
@@ -3861,6 +4233,20 @@ export function createRemoteRelayDispatcher({
3861
4233
  const problem = createSessionProblem(caller, args);
3862
4234
  if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
3863
4235
  }
4236
+ // Images toward a paired relay that cannot take them: refused before
4237
+ // anyone is asked and before anything is created or sent. This relay
4238
+ // itself always takes them.
4239
+ if (!local && IMAGE_INPUT_ACTIONS.has(action) && (args.images || args.image)) {
4240
+ try {
4241
+ await refuseWithoutImageInputs({ relay, caller, hops, conversationId, signal }, action);
4242
+ } catch (error) {
4243
+ if (error instanceof DispatchFailure) {
4244
+ return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
4245
+ }
4246
+ if (isRemoteRelayError(error)) return finish(mapRemoteError(error, relay, { action, args }));
4247
+ throw error;
4248
+ }
4249
+ }
3864
4250
  // The group a create_session names, looked up on the target before anyone
3865
4251
  // is asked, like the model: the card names it, and an unknown group (or a
3866
4252
  // target whose groups cannot be read) refuses the call, nothing created.