@oar-sh/oar 0.9.12 → 0.9.14

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 (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. package/server/uploads/image_input.md +0 -540
@@ -25,9 +25,12 @@ import {
25
25
  withRemotePromptHeader,
26
26
  } from '../../shared/remote-relay-contract.mjs';
27
27
  import { schemaFields } from '../../shared/question-schema.mjs';
28
+ import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
28
29
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
29
- import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
30
+ import { RELAY_EVENT_DEFAULT_TYPES, RELAY_EVENT_RELAY_LEVEL_TYPES } from '../../shared/relay-events.mjs';
31
+ import { checkRequestedProviderModel, isAutoModelId, providerModelLabel, unknownProviderModelText } from './provider-model-selection.mjs';
30
32
  import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
33
+ import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
31
34
  import {
32
35
  RELAY_EVENT_REMOTE_PULL_PATH,
33
36
  RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
@@ -130,6 +133,8 @@ const FAILURE_TEXT_PATTERNS = Object.freeze([
130
133
  /^relay timeout after \d+ attempts/i,
131
134
  ]);
132
135
  const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
136
+ // How many of a provider's model ids an unknown-model refusal carries.
137
+ const UNKNOWN_MODEL_LIST_MAX = 50;
133
138
  // POST /api/conversation/bootstrap refuses an effort its model does not take
134
139
  // with this code (400); POST /api/message sends the same 400 without a code,
135
140
  // but with the list below.
@@ -269,6 +274,45 @@ function matchOfferedModel(candidate, offered = []) {
269
274
  return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
270
275
  }
271
276
 
277
+ /** The models /api/models lists for a provider label ("openai-byok"). */
278
+ function catalogModelsLabelled(catalog, label) {
279
+ const providersByModel = isPlainObject(catalog?.models?.providersByModel) ? catalog.models.providersByModel : {};
280
+ return Object.entries(providersByModel)
281
+ .filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
282
+ .map(([model]) => model);
283
+ }
284
+
285
+ /** Copilot's catalogue: every model of /api/models no other provider claims alone. */
286
+ function catalogGithubModels(catalog) {
287
+ const models = catalog?.models || {};
288
+ const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
289
+ const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
290
+ return catalogModels.filter((model) => {
291
+ const providers = catalogProvidersFor(providersByModel, model);
292
+ return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
293
+ });
294
+ }
295
+
296
+ /**
297
+ * The models a provider's New Chat list offers on the target, which its
298
+ * bootstrap and message routes accept, or null when the target has no list
299
+ * for it yet (discovery has not run or failed, the route did not answer):
300
+ * then a requested model cannot be checked here.
301
+ */
302
+ function offeredModelList(catalog, provider) {
303
+ if (provider === 'github') {
304
+ const listed = catalogGithubModels(catalog);
305
+ return listed.length ? listed : null;
306
+ }
307
+ if (provider === 'openai') {
308
+ const listed = catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai);
309
+ return listed.length ? listed : null;
310
+ }
311
+ const settings = catalog?.settings?.[provider];
312
+ const listed = Array.isArray(settings?.models) ? settings.models.map(toText).filter(Boolean) : [];
313
+ return listed.length ? listed : null;
314
+ }
315
+
272
316
  /** A reasoning effort as the relays write it, or '' when the value is not one. */
273
317
  function normalizeEffort(value) {
274
318
  const effort = toText(value).toLowerCase();
@@ -1016,19 +1060,9 @@ export function createRemoteRelayDispatcher({
1016
1060
 
1017
1061
  function listProviders(catalog) {
1018
1062
  const models = catalog.models || {};
1019
- const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
1020
- const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
1021
- const modelsLabelled = (label) => Object.entries(providersByModel)
1022
- .filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
1023
- .map(([model]) => model);
1024
-
1025
1063
  return REMOTE_RELAY_PROVIDERS.map((provider) => {
1026
1064
  if (provider === 'github') {
1027
- // Copilot's catalogue is every model no other provider claims alone.
1028
- const githubModels = catalogModels.filter((model) => {
1029
- const providers = catalogProvidersFor(providersByModel, model);
1030
- return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
1031
- });
1065
+ const githubModels = catalogGithubModels(catalog);
1032
1066
  const defaultModel = toText(catalog.status.defaultModel) || toText(models.defaultModel) || toText(models.currentModel) || null;
1033
1067
  return {
1034
1068
  provider,
@@ -1060,7 +1094,7 @@ export function createRemoteRelayDispatcher({
1060
1094
  && (provider === 'claude' || provider === 'grok' || settings.configured === true);
1061
1095
  const defaultModel = toText(settings.model) || null;
1062
1096
  const listed = provider === 'openai'
1063
- ? modelsLabelled(CATALOG_PROVIDER_LABELS.openai)
1097
+ ? catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai)
1064
1098
  : (Array.isArray(settings.models) ? settings.models : []);
1065
1099
  return {
1066
1100
  provider,
@@ -1299,9 +1333,12 @@ export function createRemoteRelayDispatcher({
1299
1333
  const health = isPlainObject(task.health) ? task.health : null;
1300
1334
  const quietAfter = Number(row.taskStallMinutes) || null;
1301
1335
  const minutes = live && health ? stallMinutes(health.lastOutputAt, now()) : null;
1336
+ // The command line comes scrubbed: the relay cleans it when the worker posts it.
1337
+ const command = toText(task.command);
1302
1338
  return {
1303
1339
  taskId,
1304
1340
  output: kind !== 'none',
1341
+ ...(command ? { command: clipLine(command, 500) } : {}),
1305
1342
  ...(health ? {
1306
1343
  health: {
1307
1344
  ...(minutes !== null ? { minutesSinceOutput: minutes } : {}),
@@ -1595,7 +1632,7 @@ export function createRemoteRelayDispatcher({
1595
1632
  }
1596
1633
  const kind = toText(answer?.kind) || 'file';
1597
1634
  let note = null;
1598
- if (kind === 'none') note = toText(answer?.message) || 'Output is not available for this provider.';
1635
+ if (kind === 'none') note = toText(answer?.message) || TASK_OUTPUT_NONE_MESSAGES.provider;
1599
1636
  else if (answer?.pending) note = 'The session\'s worker was asked for the output; call again in a few seconds.';
1600
1637
  else if (answer?.unavailable) note = 'The output file is not there any more (the provider cleaned it up).';
1601
1638
  else if (kind === 'snapshot') note = 'Only the end of this task\'s output is available (its provider keeps no file).';
@@ -1785,16 +1822,24 @@ export function createRemoteRelayDispatcher({
1785
1822
  }
1786
1823
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1787
1824
  const provider = toText(runtime.providerType).toLowerCase() || 'github';
1825
+ // Checked before any approval card (runRelayAction) when the session
1826
+ // could be read there; here otherwise.
1827
+ const settled = args.model
1828
+ ? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model))
1829
+ : null;
1830
+ const requestedModel = settled?.model || '';
1831
+ const modelNote = settled?.note || null;
1788
1832
  // Without a model the remote falls back to its catalogue's current one and
1789
1833
  // silently switches the session; keep the model the session already uses.
1790
1834
  // Grok pins its model at bootstrap and refuses any other, so it gets none.
1791
- const model = args.model
1835
+ const model = requestedModel
1792
1836
  || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
1793
1837
  const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
1794
1838
  // The remote runs a prompt that names no effort with its provider's
1795
1839
  // default, not with the one the session is set to: pass that one on, so
1796
1840
  // the session keeps its own. This session's effort is never mirrored here.
1797
- const requested = normalizeEffort(args.effort);
1841
+ // A Copilot model named with its effort ("gpt-5.4-high") brings that effort.
1842
+ const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
1798
1843
  const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
1799
1844
  const origin = await buildOrigin(ctx);
1800
1845
  const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
@@ -1802,11 +1847,91 @@ export function createRemoteRelayDispatcher({
1802
1847
  source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
1803
1848
  note: null,
1804
1849
  });
1805
- return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds, extra: effortResult(choice) });
1850
+ return deliverAndWait('send', ctx, {
1851
+ session,
1852
+ sent,
1853
+ waitSeconds: args.wait_seconds,
1854
+ extra: { ...(modelNote ? { modelNote } : {}), ...effortResult(choice) },
1855
+ });
1856
+ }
1857
+
1858
+ /**
1859
+ * A model the agent named, checked against the list New Chat offers for
1860
+ * `provider` on the target (offeredModelList). Throws a DispatchFailure
1861
+ * (unknownModel) for one it does not offer, before anything is created or
1862
+ * sent. Returns `{ model, note }`: the id to use (the provider's casing, a
1863
+ * Claude alias resolved) and what the agent should know about it, the
1864
+ * model left unchecked for want of a list included.
1865
+ */
1866
+ function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [] }) {
1867
+ const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
1868
+ const offered = catalog ? offeredModelList(catalog, provider) : null;
1869
+ const check = checkRequestedProviderModel({
1870
+ requestedModel: requested,
1871
+ provider,
1872
+ // What the message route accepts besides (send): the provider's whole
1873
+ // discovered list and the session's own model. Never a list by itself.
1874
+ models: offered ? [...offered, ...extraModels.map(toText).filter(Boolean)] : null,
1875
+ defaultModel: entry?.defaultModel || '',
1876
+ effortsFor: (base) => catalogEffortsFor(catalog?.models, provider, base),
1877
+ });
1878
+ const where = `on relay "${ctx.relay.name}"`;
1879
+ const label = providerModelLabel(provider);
1880
+ if (check.status === 'unknown') {
1881
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
1882
+ `${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : 'Nothing was sent.'}`,
1883
+ {
1884
+ provider,
1885
+ model: requested,
1886
+ suggestions: check.suggestions,
1887
+ models: check.models.slice(0, UNKNOWN_MODEL_LIST_MAX),
1888
+ note: 'Pass one of these ids (relay_info lists them per provider), or leave model out.',
1889
+ });
1890
+ }
1891
+ if (check.status === 'unchecked') {
1892
+ return {
1893
+ model: requested,
1894
+ note: `Model '${requested}' could not be checked: ${label} ${where} has no model list yet (its model discovery has not run or failed), so ${creating ? 'the session was created' : 'the prompt was sent'} with it as asked.`,
1895
+ };
1896
+ }
1897
+ const tierNote = check.tierDropped ? ` ${label} ${where} lists no "[1m]" id for it, so it runs with the standard context.` : '';
1898
+ if (check.status === 'alias') {
1899
+ return { model: check.model, note: `'${requested}' was taken as ${check.model}, the newest ${label} model of that name ${where}.${tierNote}` };
1900
+ }
1901
+ // A Copilot model with its effort: the bootstrap and the message route
1902
+ // know the listed id, so the effort goes on apart.
1903
+ if (check.base) return { model: check.base, effort: check.effort, note: `'${requested}' was taken as ${check.base} with effort ${check.effort}.` };
1904
+ if (check.tierDropped) return { model: check.model, note: `'${requested}' was taken as ${check.model}.${tierNote}` };
1905
+ // The same id in the provider's casing needs no word; another one does.
1906
+ if (check.model.toLowerCase() !== requested.toLowerCase()) {
1907
+ return { model: check.model, note: `'${requested}' is listed as ${check.model} ${where}; that is the model used.` };
1908
+ }
1909
+ return { model: check.model, note: null };
1910
+ }
1911
+
1912
+ /** settleRequestedModel for a send to `conversation` (its detail payload); an unread catalogue leaves it unchecked. */
1913
+ async function settleSendModel(ctx, conversation, requested) {
1914
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1915
+ const provider = toText(runtime.providerType).toLowerCase() || 'github';
1916
+ let catalog = null;
1917
+ try {
1918
+ catalog = await loadCatalog(ctx);
1919
+ } catch {
1920
+ // No catalogue: the model goes unchecked, and the note says so.
1921
+ }
1922
+ const settings = catalog?.settings?.[provider];
1923
+ return settleRequestedModel(ctx, catalog, provider, requested, {
1924
+ creating: false,
1925
+ extraModels: [
1926
+ ...(Array.isArray(settings?.availableModels) ? settings.availableModels : []),
1927
+ runtime.providerModel,
1928
+ runtime.model,
1929
+ ],
1930
+ });
1806
1931
  }
1807
1932
 
1808
1933
  async function createSession(ctx, args) {
1809
- const catalog = await loadCatalog(ctx);
1934
+ const catalog = ctx.catalog || await loadCatalog(ctx);
1810
1935
  const providers = buildProviders(catalog);
1811
1936
  const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
1812
1937
  const provider = createProvider(ctx.caller, args);
@@ -1821,6 +1946,16 @@ export function createRemoteRelayDispatcher({
1821
1946
  let model = args.model || '';
1822
1947
  let modelSource = model ? 'requested' : '';
1823
1948
  let note = null;
1949
+ let variantEffort = '';
1950
+ if (model) {
1951
+ // Refused here when the provider does not offer it: a session created
1952
+ // with an id its provider never runs stays empty, and every send to it
1953
+ // fails.
1954
+ const settled = settleRequestedModel(ctx, catalog, provider, model, { creating: true });
1955
+ model = settled.model;
1956
+ note = settled.note;
1957
+ variantEffort = normalizeEffort(settled.effort);
1958
+ }
1824
1959
  // A cloud session without a requested model gets the Claude Cloud tab's
1825
1960
  // default, as New Chat does: the bootstrap picks it, nothing is mirrored.
1826
1961
  if (!model && cloud) {
@@ -1842,7 +1977,7 @@ export function createRemoteRelayDispatcher({
1842
1977
 
1843
1978
  // The effort mirrors the caller like the model does: this session's when
1844
1979
  // the remote lists it for the model (or lists nothing), else the remote's.
1845
- let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
1980
+ let choice = { effort: normalizeEffort(args.effort) || variantEffort, source: EFFORT_SOURCES.requested, note: null };
1846
1981
  if (!choice.effort && cloud) {
1847
1982
  // A cloud session takes no effort: none is mirrored, none is reported.
1848
1983
  choice = { effort: '', source: '', note: null };
@@ -1898,6 +2033,11 @@ export function createRemoteRelayDispatcher({
1898
2033
  `Relay "${ctx.relay.name}" did not return the new session's id.`);
1899
2034
  }
1900
2035
  const boundModel = toText(bootstrap?.selectedModel) || model;
2036
+ // A relay that swapped the asked model for another (one that predates
2037
+ // these checks does for Copilot) is named, never passed over in silence.
2038
+ if (modelSource === 'requested' && model && !isAutoModelId(model) && boundModel.toLowerCase() !== model.toLowerCase()) {
2039
+ note = [note, `Relay "${ctx.relay.name}" started the session on ${boundModel}, not on ${model} as asked.`].filter(Boolean).join(' ');
2040
+ }
1901
2041
  let sent;
1902
2042
  try {
1903
2043
  const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
@@ -1993,7 +2133,14 @@ export function createRemoteRelayDispatcher({
1993
2133
  if (!labels.includes(label)) labels.push(label);
1994
2134
  }
1995
2135
  // The card's own format: the picked labels, then any typed text, ", "-joined.
1996
- const extra = toText(args.answer);
2136
+ // A card that takes no typed text gets an option or nothing.
2137
+ let extra = toText(args.answer);
2138
+ if (extra && question.allowFreeform === false) {
2139
+ extra = matchQuestionChoice(extra, options);
2140
+ if (!extra) {
2141
+ throw invalidInput(`This question takes only its options (${options.join(', ')}), no typed answer; pass one of them in choices.`);
2142
+ }
2143
+ }
1997
2144
  if (extra && !labels.includes(extra)) labels.push(extra);
1998
2145
  const text = labels.join(', ');
1999
2146
  return { answer: text, text };
@@ -2193,7 +2340,7 @@ export function createRemoteRelayDispatcher({
2193
2340
  if (!ctx.relay.local) return subscribeRemoteAction(ctx, args);
2194
2341
  const service = eventService(ctx);
2195
2342
  const all = args.scope === 'all';
2196
- const ids = all ? [] : args.sessions;
2343
+ const ids = all ? [] : (args.sessions || []);
2197
2344
  // Refused before anyone is asked: the conversation itself, unknown ids.
2198
2345
  if (!all && ids.includes(ctx.conversationId)) {
2199
2346
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
@@ -2210,11 +2357,15 @@ export function createRemoteRelayDispatcher({
2210
2357
  titles.push({ id, title: toText(page.title) || null });
2211
2358
  }
2212
2359
  const events = Array.isArray(args.events) && args.events.length ? args.events : [...RELAY_EVENT_DEFAULT_TYPES];
2213
- const decision = await ensureWatchApproval(ctx, {
2214
- all,
2215
- watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
2216
- });
2217
- if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2360
+ // Only relay_restarted, no session: nothing of another conversation is
2361
+ // watched, so there is nothing to ask the user about.
2362
+ if (all || ids.length) {
2363
+ const decision = await ensureWatchApproval(ctx, {
2364
+ all,
2365
+ watch: { kind: 'subscribe', all, sessions: titles, events, once: args.once === true, heartbeatMinutes: args.heartbeat_minutes ?? null },
2366
+ });
2367
+ if (!decision.approved) throw new DispatchFailure(403, decision.code, decision.error);
2368
+ }
2218
2369
  const subscription = relayEventCall(() => service.subscribe({
2219
2370
  watcherConversationId: ctx.conversationId,
2220
2371
  scope: all ? 'all' : 'sessions',
@@ -2225,8 +2376,9 @@ export function createRemoteRelayDispatcher({
2225
2376
  stalledMinutes: args.stalled_minutes ?? null,
2226
2377
  heartbeatMinutes: args.heartbeat_minutes ?? null,
2227
2378
  }));
2379
+ const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'relay_restarted');
2228
2380
  return {
2229
- summary: `subscribe → ${ctx.relay.name}: ${all ? 'all sessions' : `${ids.length} session${ids.length === 1 ? '' : 's'}`} (${subscription.id})`,
2381
+ summary: `subscribe → ${ctx.relay.name}: ${what} (${subscription.id})`,
2230
2382
  subscription,
2231
2383
  note: EVENT_NOTE,
2232
2384
  };
@@ -2239,6 +2391,14 @@ export function createRemoteRelayDispatcher({
2239
2391
  * mirror; the link fetches the events from then on.
2240
2392
  */
2241
2393
  async function subscribeRemoteAction(ctx, args) {
2394
+ const relayLevel = (args.events || []).filter((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
2395
+ if (relayLevel.length) {
2396
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2397
+ `${relayLevel.join(', ')} is about the relay you run on: subscribe to it on "${REMOTE_RELAY_LOCAL_ALIAS}". An outage of relay "${ctx.relay.name}" shows as link_lost and link_restored.`);
2398
+ }
2399
+ if (!args.sessions?.length && args.scope !== 'all') {
2400
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, 'subscribe on a paired relay needs sessions (ids from list_sessions) or ["all"].');
2401
+ }
2242
2402
  const service = eventService(ctx);
2243
2403
  const all = args.scope === 'all';
2244
2404
  relayEventCall(() => service.checkWatcherCapacity(ctx.conversationId));
@@ -2679,10 +2839,39 @@ export function createRemoteRelayDispatcher({
2679
2839
  { relay: relay.name, retryAfterSeconds }));
2680
2840
  }
2681
2841
 
2842
+ // The target's catalogue, loaded before anyone is asked for approval when
2843
+ // the agent names a model: a card for a model that does not exist there
2844
+ // would be a question about nothing. createSession reuses it.
2845
+ let catalog = null;
2846
+ let settledModel = null;
2682
2847
  if (action === 'create_session') {
2683
2848
  const problem = createSessionProblem(caller, args);
2684
2849
  if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
2685
2850
  }
2851
+ if ((action === 'create_session' || action === 'send') && args.model) {
2852
+ const early = { relay, caller, hops, conversationId, signal };
2853
+ try {
2854
+ if (action === 'create_session') {
2855
+ catalog = await loadCatalog(early);
2856
+ const provider = createProvider(caller, args);
2857
+ // A provider not set up there answers as such (createSession), not
2858
+ // with a model it has no list for.
2859
+ if (listProviders(catalog).find((entry) => entry.provider === provider)?.configured) {
2860
+ settleRequestedModel(early, catalog, provider, args.model, { creating: true });
2861
+ }
2862
+ } else {
2863
+ const conversation = await call(early, 'GET', sessionPath(args.session), { query: { limit: 1 } });
2864
+ settledModel = await settleSendModel(early, conversation, args.model);
2865
+ }
2866
+ } catch (error) {
2867
+ if (error instanceof DispatchFailure) {
2868
+ return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
2869
+ }
2870
+ // The target did not answer: the call itself reports that.
2871
+ catalog = null;
2872
+ settledModel = null;
2873
+ }
2874
+ }
2686
2875
 
2687
2876
  const localCreate = local && action === 'create_session';
2688
2877
  let holdsLocalCreate = false;
@@ -2756,7 +2945,7 @@ export function createRemoteRelayDispatcher({
2756
2945
  }
2757
2946
  }
2758
2947
 
2759
- const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
2948
+ const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}) };
2760
2949
  const body = await ACTIONS[action](ctx, args);
2761
2950
  if (prompting) agentPrompts(conversationId).count += 1;
2762
2951
  const { summary, ...rest } = body;