@oar-sh/oar 0.9.11 → 0.9.13

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 (90) hide show
  1. package/CHANGELOG.md +332 -0
  2. package/README.md +10 -10
  3. package/package.json +1 -1
  4. package/server/README.md +201 -12
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  6. package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
  7. package/server/claude-worker/claude-session-process.mjs +179 -0
  8. package/server/claude-worker/claude-session-worker.mjs +17 -0
  9. package/server/claude-worker/task-output-discovery.mjs +152 -0
  10. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
  13. package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
  14. package/server/copilot-worker/copilot-task-settle.mjs +96 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/acp-host-services.mjs +48 -6
  17. package/server/grok-worker/grok-background-tasks.mjs +248 -0
  18. package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
  19. package/server/grok-worker/grok-session-worker.mjs +33 -1
  20. package/server/grok-worker/grok-turn-runner.mjs +11 -0
  21. package/server/migrations/0017-conversation-history-indexes.mjs +89 -0
  22. package/server/migrations/0018-background-task-rows.mjs +101 -0
  23. package/server/public/app/action-confirmations.js +12 -15
  24. package/server/public/app/api-client.js +70 -3
  25. package/server/public/app/background-tasks-view.mjs +334 -16
  26. package/server/public/app/bootstrap.js +102 -5
  27. package/server/public/app/claude-background-command-limit-options.mjs +21 -0
  28. package/server/public/app/conversation-payload-cache.mjs +357 -0
  29. package/server/public/app/conversation-preferences.mjs +10 -4
  30. package/server/public/app/conversation-turn-state.mjs +1 -1
  31. package/server/public/app/conversation-view.js +462 -8
  32. package/server/public/app/conversation-window-retention.mjs +96 -0
  33. package/server/public/app/host-suspend-ui.js +45 -44
  34. package/server/public/app/journal-view.js +259 -67
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
  37. package/server/public/app/relay-restart-note-view.mjs +36 -0
  38. package/server/public/app/relay-restart-ui.js +568 -0
  39. package/server/public/app/settings-modal.js +162 -2
  40. package/server/public/app/socket-handlers.js +59 -7
  41. package/server/public/app/store.js +25 -0
  42. package/server/public/app/subagent-viewer.mjs +7 -1
  43. package/server/public/app/task-console-render.mjs +401 -0
  44. package/server/public/app/task-console-watch.mjs +42 -0
  45. package/server/public/app/task-console.mjs +730 -0
  46. package/server/public/app/task-output-text.mjs +243 -0
  47. package/server/public/app/transcript-island.mjs +73 -0
  48. package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
  49. package/server/public/app/transcript-jump-bottom.mjs +68 -11
  50. package/server/public/app/transcript-reading-anchor.mjs +256 -0
  51. package/server/public/app/transcript-reload-freshness.mjs +17 -1
  52. package/server/public/app/transcript-viewport-anchor.mjs +20 -1
  53. package/server/public/index.html +306 -6
  54. package/server/relay-tools.md +6 -0
  55. package/server/repositories/message-repository.mjs +11 -0
  56. package/server/repositories/question-repository.mjs +4 -0
  57. package/server/repositories/remote-relay-repository.mjs +4 -2
  58. package/server/routes/messages-routes.mjs +397 -23
  59. package/server/routes/relay-board-routes.mjs +19 -2
  60. package/server/routes/relay-restart-routes.mjs +73 -0
  61. package/server/routes/sessions-routes.mjs +208 -66
  62. package/server/server-runtime.mjs +269 -137
  63. package/server/services/about-service.mjs +13 -12
  64. package/server/services/background-task-persistence.mjs +133 -0
  65. package/server/services/background-task-store.mjs +470 -6
  66. package/server/services/cli-process-runner.mjs +4 -21
  67. package/server/services/conversation-history-page.mjs +199 -0
  68. package/server/services/conversation-model-fallback.mjs +57 -0
  69. package/server/services/host-suspend-command.mjs +2 -1
  70. package/server/services/preview-proxy-server.mjs +14 -1
  71. package/server/services/process-list-service.mjs +2 -1
  72. package/server/services/provider-model-selection.mjs +195 -0
  73. package/server/services/push-dispatch-service.mjs +9 -2
  74. package/server/services/relay-event-service.mjs +27 -9
  75. package/server/services/relay-prompt-sanitizer.mjs +18 -4
  76. package/server/services/relay-restart-picture.mjs +209 -0
  77. package/server/services/relay-restart-queue.mjs +380 -0
  78. package/server/services/relay-shutdown-gate.mjs +4 -1
  79. package/server/services/remote-relay-dispatcher.mjs +316 -20
  80. package/server/services/restart-resume-service.mjs +303 -0
  81. package/server/services/session-activity-tracker.mjs +6 -2
  82. package/server/services/session-worker-process-service.mjs +6 -4
  83. package/server/services/task-output-rooms.mjs +259 -0
  84. package/server/services/task-output-service.mjs +761 -0
  85. package/shared/claude-background-command-limit.mjs +83 -0
  86. package/shared/relay-events.mjs +55 -2
  87. package/shared/remote-relay-contract.mjs +47 -10
  88. package/shared/task-output-text.mjs +850 -0
  89. package/shared/worker-runtime/task-output-spool.mjs +181 -0
  90. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -27,6 +27,9 @@ import {
27
27
  import { schemaFields } from '../../shared/question-schema.mjs';
28
28
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
29
29
  import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
30
+ import { checkRequestedProviderModel, isAutoModelId, providerModelLabel, unknownProviderModelText } from './provider-model-selection.mjs';
31
+ import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
32
+ import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
30
33
  import {
31
34
  RELAY_EVENT_REMOTE_PULL_PATH,
32
35
  RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
@@ -129,6 +132,8 @@ const FAILURE_TEXT_PATTERNS = Object.freeze([
129
132
  /^relay timeout after \d+ attempts/i,
130
133
  ]);
131
134
  const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
135
+ // How many of a provider's model ids an unknown-model refusal carries.
136
+ const UNKNOWN_MODEL_LIST_MAX = 50;
132
137
  // POST /api/conversation/bootstrap refuses an effort its model does not take
133
138
  // with this code (400); POST /api/message sends the same 400 without a code,
134
139
  // but with the list below.
@@ -268,6 +273,45 @@ function matchOfferedModel(candidate, offered = []) {
268
273
  return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
269
274
  }
270
275
 
276
+ /** The models /api/models lists for a provider label ("openai-byok"). */
277
+ function catalogModelsLabelled(catalog, label) {
278
+ const providersByModel = isPlainObject(catalog?.models?.providersByModel) ? catalog.models.providersByModel : {};
279
+ return Object.entries(providersByModel)
280
+ .filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
281
+ .map(([model]) => model);
282
+ }
283
+
284
+ /** Copilot's catalogue: every model of /api/models no other provider claims alone. */
285
+ function catalogGithubModels(catalog) {
286
+ const models = catalog?.models || {};
287
+ const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
288
+ const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
289
+ return catalogModels.filter((model) => {
290
+ const providers = catalogProvidersFor(providersByModel, model);
291
+ return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
292
+ });
293
+ }
294
+
295
+ /**
296
+ * The models a provider's New Chat list offers on the target, which its
297
+ * bootstrap and message routes accept, or null when the target has no list
298
+ * for it yet (discovery has not run or failed, the route did not answer):
299
+ * then a requested model cannot be checked here.
300
+ */
301
+ function offeredModelList(catalog, provider) {
302
+ if (provider === 'github') {
303
+ const listed = catalogGithubModels(catalog);
304
+ return listed.length ? listed : null;
305
+ }
306
+ if (provider === 'openai') {
307
+ const listed = catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai);
308
+ return listed.length ? listed : null;
309
+ }
310
+ const settings = catalog?.settings?.[provider];
311
+ const listed = Array.isArray(settings?.models) ? settings.models.map(toText).filter(Boolean) : [];
312
+ return listed.length ? listed : null;
313
+ }
314
+
271
315
  /** A reasoning effort as the relays write it, or '' when the value is not one. */
272
316
  function normalizeEffort(value) {
273
317
  const effort = toText(value).toLowerCase();
@@ -1015,19 +1059,9 @@ export function createRemoteRelayDispatcher({
1015
1059
 
1016
1060
  function listProviders(catalog) {
1017
1061
  const models = catalog.models || {};
1018
- const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
1019
- const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
1020
- const modelsLabelled = (label) => Object.entries(providersByModel)
1021
- .filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
1022
- .map(([model]) => model);
1023
-
1024
1062
  return REMOTE_RELAY_PROVIDERS.map((provider) => {
1025
1063
  if (provider === 'github') {
1026
- // Copilot's catalogue is every model no other provider claims alone.
1027
- const githubModels = catalogModels.filter((model) => {
1028
- const providers = catalogProvidersFor(providersByModel, model);
1029
- return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
1030
- });
1064
+ const githubModels = catalogGithubModels(catalog);
1031
1065
  const defaultModel = toText(catalog.status.defaultModel) || toText(models.defaultModel) || toText(models.currentModel) || null;
1032
1066
  return {
1033
1067
  provider,
@@ -1059,7 +1093,7 @@ export function createRemoteRelayDispatcher({
1059
1093
  && (provider === 'claude' || provider === 'grok' || settings.configured === true);
1060
1094
  const defaultModel = toText(settings.model) || null;
1061
1095
  const listed = provider === 'openai'
1062
- ? modelsLabelled(CATALOG_PROVIDER_LABELS.openai)
1096
+ ? catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai)
1063
1097
  : (Array.isArray(settings.models) ? settings.models : []);
1064
1098
  return {
1065
1099
  provider,
@@ -1281,7 +1315,40 @@ export function createRemoteRelayDispatcher({
1281
1315
  * `lastActivityAt` of a session, from the relay's `sessionState` /
1282
1316
  * `sessionTasks`. A relay that predates them gives none.
1283
1317
  */
1284
- function sessionActivityFields(row) {
1318
+ /**
1319
+ * The console facts read_session adds to a task (`row` is the conversation
1320
+ * payload): `taskId` for read_task_output, `output` whether there is any to
1321
+ * read, and `health` (minutes since the last output, the quiet threshold,
1322
+ * test-run counts) for a task with an output file.
1323
+ */
1324
+ function taskConsoleFields(row, id) {
1325
+ const taskId = toText(id);
1326
+ if (!taskId || !isPlainObject(row)) return {};
1327
+ const live = (Array.isArray(row.backgroundTasks) ? row.backgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
1328
+ const ended = live ? null : (Array.isArray(row.endedBackgroundTasks) ? row.endedBackgroundTasks : []).find((task) => toText(task?.taskId) === taskId);
1329
+ const task = live || ended;
1330
+ if (!task) return {};
1331
+ const kind = toText(task.outputKind) || (toText(task.outputPath) ? 'file' : 'none');
1332
+ const health = isPlainObject(task.health) ? task.health : null;
1333
+ const quietAfter = Number(row.taskStallMinutes) || null;
1334
+ const minutes = live && health ? stallMinutes(health.lastOutputAt, now()) : null;
1335
+ // The command line comes scrubbed: the relay cleans it when the worker posts it.
1336
+ const command = toText(task.command);
1337
+ return {
1338
+ taskId,
1339
+ output: kind !== 'none',
1340
+ ...(command ? { command: clipLine(command, 500) } : {}),
1341
+ ...(health ? {
1342
+ health: {
1343
+ ...(minutes !== null ? { minutesSinceOutput: minutes } : {}),
1344
+ ...(quietAfter && minutes !== null ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
1345
+ ...(isPlainObject(health.counts) ? { counts: { runner: toText(health.runner) || null, ...health.counts } } : {}),
1346
+ },
1347
+ } : {}),
1348
+ };
1349
+ }
1350
+
1351
+ function sessionActivityFields(row, { console: withConsole = false } = {}) {
1285
1352
  const state = toText(row?.sessionState);
1286
1353
  if (!SESSION_STATES.has(state)) return {};
1287
1354
  const tasks = (Array.isArray(row?.sessionTasks) ? row.sessionTasks : [])
@@ -1298,6 +1365,7 @@ export function createRemoteRelayDispatcher({
1298
1365
  ...(toText(task.summary) && toText(task.status) !== 'running' ? { summary: clipLine(task.summary, 200) } : {}),
1299
1366
  ...(task.endedAt ? { endedAt: task.endedAt } : {}),
1300
1367
  ...(toText(task.id) ? { id: toText(task.id) } : {}),
1368
+ ...(withConsole ? taskConsoleFields(row, task.id) : {}),
1301
1369
  }));
1302
1370
  return {
1303
1371
  state,
@@ -1487,7 +1555,7 @@ export function createRemoteRelayDispatcher({
1487
1555
  cwd: toText(conversation?.currentWorkspaceRootPath) || null,
1488
1556
  archived: conversation?.archived === true,
1489
1557
  active: conversation?.activeTurn === true || isBusyState(conversation?.sessionState),
1490
- ...sessionActivityFields(conversation),
1558
+ ...sessionActivityFields(conversation, { console: true }),
1491
1559
  ...(via ? { via } : {}),
1492
1560
  ...(group ? { group } : {}),
1493
1561
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
@@ -1500,6 +1568,101 @@ export function createRemoteRelayDispatcher({
1500
1568
  };
1501
1569
  }
1502
1570
 
1571
+ /**
1572
+ * read_task_output: the end of a background shell or Monitor task's
1573
+ * output (GET …/background-task/:taskId/output on the target relay, which
1574
+ * scrubs it), as plain lines without colours, at most `lines` lines and
1575
+ * 32 KB; minutes since its last output; test-run counts when it is one.
1576
+ */
1577
+ async function readTaskOutput(ctx, args) {
1578
+ let answer;
1579
+ try {
1580
+ answer = await call(ctx, 'GET', sessionPath(args.session, `/background-task/${encodeURIComponent(args.task_id)}/output`), {
1581
+ query: { lines: args.lines },
1582
+ });
1583
+ } catch (error) {
1584
+ const status = Number(error?.status) || null;
1585
+ if (isRemoteRelayError(error) && (status === 404 || toText(error.code) === REMOTE_RELAY_ERROR_CODES.notFound)) {
1586
+ const body = isPlainObject(error.remoteBody) ? error.remoteBody : {};
1587
+ if (toText(body.code) === 'task-not-found') {
1588
+ throw new DispatchFailure(404, REMOTE_RELAY_ERROR_CODES.notFound,
1589
+ `Task ${args.task_id} is not running in session ${args.session} on relay "${ctx.relay.name}" and did not end there within the last hour; read_session lists the session's tasks.`);
1590
+ }
1591
+ if (!/conversation not found/i.test(toText(body.error))) {
1592
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unsupported,
1593
+ `Relay "${ctx.relay.name}" cannot show background task output (it runs an older OAR version).`);
1594
+ }
1595
+ }
1596
+ throw error;
1597
+ }
1598
+ const model = Array.isArray(answer?.lines) ? answer.lines : [];
1599
+ const task = isPlainObject(answer?.task) ? answer.task : {};
1600
+ const ended = answer?.ended === true;
1601
+ // Plain text, a repeat folded as ×N; cleaned again, whatever the other
1602
+ // side did.
1603
+ const rendered = model.map((line) => {
1604
+ const text = stripSgr(cleanTaskOutput(String(line?.text ?? '')));
1605
+ const count = Number(line?.count) > 1 ? Number(line.count) : 1;
1606
+ return count > 1 ? `${text} [×${count}]` : text;
1607
+ });
1608
+ let tail = rendered.slice(-args.lines);
1609
+ let cut = tail.length < rendered.length;
1610
+ let bytes = 0;
1611
+ let keepFrom = tail.length;
1612
+ for (let index = tail.length - 1; index >= 0; index -= 1) {
1613
+ bytes += Buffer.byteLength(tail[index], 'utf8') + 1;
1614
+ if (bytes > REMOTE_RELAY_LIMITS.taskOutputBytesMax) break;
1615
+ keepFrom = index;
1616
+ }
1617
+ if (keepFrom > 0) {
1618
+ tail = tail.slice(keepFrom);
1619
+ cut = true;
1620
+ }
1621
+ const health = isPlainObject(answer?.health) ? answer.health : null;
1622
+ const lastOutputAt = Number(health?.lastOutputAt) || Number(answer?.mtime) || null;
1623
+ const minutes = !ended && lastOutputAt ? stallMinutes(lastOutputAt, now()) : null;
1624
+ const quietAfter = Number(answer?.taskStallMinutes) || null;
1625
+ let counts = null;
1626
+ if (isPlainObject(health?.counts)) {
1627
+ counts = { runner: toText(health.runner) || null, ...health.counts, final: health.final === true };
1628
+ } else {
1629
+ const parsed = parseRunnerSummary(model);
1630
+ if (parsed) counts = { runner: parsed.runner, passed: parsed.passed, failed: parsed.failed, skipped: parsed.skipped, final: parsed.final };
1631
+ }
1632
+ const kind = toText(answer?.kind) || 'file';
1633
+ let note = null;
1634
+ if (kind === 'none') note = toText(answer?.message) || TASK_OUTPUT_NONE_MESSAGES.provider;
1635
+ else if (answer?.pending) note = 'The session\'s worker was asked for the output; call again in a few seconds.';
1636
+ else if (answer?.unavailable) note = 'The output file is not there any more (the provider cleaned it up).';
1637
+ else if (kind === 'snapshot') note = 'Only the end of this task\'s output is available (its provider keeps no file).';
1638
+ const status = ended ? (toText(answer?.status) || 'unknown') : 'running';
1639
+ const exitCode = Number.isInteger(answer?.exitCode) ? answer.exitCode : null;
1640
+ const parts = [`${tail.length} line${tail.length === 1 ? '' : 's'}`, ended ? `ended (${status}${exitCode !== null ? `, exit ${exitCode}` : ''})` : 'running'];
1641
+ if (minutes !== null) parts.push(`last output ${minutes} min ago`);
1642
+ if (counts) parts.push(`${counts.passed} passed, ${counts.failed} failed`);
1643
+ return {
1644
+ summary: `read_task_output → ${ctx.relay.name} session ${shortId(args.session)} task ${shortId(args.task_id)}: ${parts.join(', ')}`,
1645
+ task: {
1646
+ id: args.task_id,
1647
+ description: toText(task.description),
1648
+ type: toText(task.taskType) || null,
1649
+ startedAt: task.startedAt || null,
1650
+ status,
1651
+ exitCode,
1652
+ ...(task.endedAt ? { endedAt: task.endedAt } : {}),
1653
+ },
1654
+ stall: minutes === null ? null : {
1655
+ minutes,
1656
+ since: new Date(lastOutputAt).toISOString(),
1657
+ ...(quietAfter ? { quiet: minutes >= quietAfter, quietAfterMinutes: quietAfter } : {}),
1658
+ },
1659
+ counts,
1660
+ tail,
1661
+ truncated: cut || answer?.hasMoreBefore === true,
1662
+ ...(note ? { note } : {}),
1663
+ };
1664
+ }
1665
+
1503
1666
  function outcomeSummary(action, ctx, outcome) {
1504
1667
  const questions = Array.isArray(outcome.pendingQuestions) ? outcome.pendingQuestions.length : 0;
1505
1668
  return `${action} → ${ctx.relay.name} session ${shortId(outcome.session)}: ${outcome.status}`
@@ -1658,16 +1821,24 @@ export function createRemoteRelayDispatcher({
1658
1821
  }
1659
1822
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1660
1823
  const provider = toText(runtime.providerType).toLowerCase() || 'github';
1824
+ // Checked before any approval card (runRelayAction) when the session
1825
+ // could be read there; here otherwise.
1826
+ const settled = args.model
1827
+ ? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model))
1828
+ : null;
1829
+ const requestedModel = settled?.model || '';
1830
+ const modelNote = settled?.note || null;
1661
1831
  // Without a model the remote falls back to its catalogue's current one and
1662
1832
  // silently switches the session; keep the model the session already uses.
1663
1833
  // Grok pins its model at bootstrap and refuses any other, so it gets none.
1664
- const model = args.model
1834
+ const model = requestedModel
1665
1835
  || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
1666
1836
  const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
1667
1837
  // The remote runs a prompt that names no effort with its provider's
1668
1838
  // default, not with the one the session is set to: pass that one on, so
1669
1839
  // the session keeps its own. This session's effort is never mirrored here.
1670
- const requested = normalizeEffort(args.effort);
1840
+ // A Copilot model named with its effort ("gpt-5.4-high") brings that effort.
1841
+ const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
1671
1842
  const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
1672
1843
  const origin = await buildOrigin(ctx);
1673
1844
  const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
@@ -1675,11 +1846,91 @@ export function createRemoteRelayDispatcher({
1675
1846
  source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
1676
1847
  note: null,
1677
1848
  });
1678
- return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds, extra: effortResult(choice) });
1849
+ return deliverAndWait('send', ctx, {
1850
+ session,
1851
+ sent,
1852
+ waitSeconds: args.wait_seconds,
1853
+ extra: { ...(modelNote ? { modelNote } : {}), ...effortResult(choice) },
1854
+ });
1855
+ }
1856
+
1857
+ /**
1858
+ * A model the agent named, checked against the list New Chat offers for
1859
+ * `provider` on the target (offeredModelList). Throws a DispatchFailure
1860
+ * (unknownModel) for one it does not offer, before anything is created or
1861
+ * sent. Returns `{ model, note }`: the id to use (the provider's casing, a
1862
+ * Claude alias resolved) and what the agent should know about it, the
1863
+ * model left unchecked for want of a list included.
1864
+ */
1865
+ function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [] }) {
1866
+ const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
1867
+ const offered = catalog ? offeredModelList(catalog, provider) : null;
1868
+ const check = checkRequestedProviderModel({
1869
+ requestedModel: requested,
1870
+ provider,
1871
+ // What the message route accepts besides (send): the provider's whole
1872
+ // discovered list and the session's own model. Never a list by itself.
1873
+ models: offered ? [...offered, ...extraModels.map(toText).filter(Boolean)] : null,
1874
+ defaultModel: entry?.defaultModel || '',
1875
+ effortsFor: (base) => catalogEffortsFor(catalog?.models, provider, base),
1876
+ });
1877
+ const where = `on relay "${ctx.relay.name}"`;
1878
+ const label = providerModelLabel(provider);
1879
+ if (check.status === 'unknown') {
1880
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
1881
+ `${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : 'Nothing was sent.'}`,
1882
+ {
1883
+ provider,
1884
+ model: requested,
1885
+ suggestions: check.suggestions,
1886
+ models: check.models.slice(0, UNKNOWN_MODEL_LIST_MAX),
1887
+ note: 'Pass one of these ids (relay_info lists them per provider), or leave model out.',
1888
+ });
1889
+ }
1890
+ if (check.status === 'unchecked') {
1891
+ return {
1892
+ model: requested,
1893
+ 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.`,
1894
+ };
1895
+ }
1896
+ const tierNote = check.tierDropped ? ` ${label} ${where} lists no "[1m]" id for it, so it runs with the standard context.` : '';
1897
+ if (check.status === 'alias') {
1898
+ return { model: check.model, note: `'${requested}' was taken as ${check.model}, the newest ${label} model of that name ${where}.${tierNote}` };
1899
+ }
1900
+ // A Copilot model with its effort: the bootstrap and the message route
1901
+ // know the listed id, so the effort goes on apart.
1902
+ if (check.base) return { model: check.base, effort: check.effort, note: `'${requested}' was taken as ${check.base} with effort ${check.effort}.` };
1903
+ if (check.tierDropped) return { model: check.model, note: `'${requested}' was taken as ${check.model}.${tierNote}` };
1904
+ // The same id in the provider's casing needs no word; another one does.
1905
+ if (check.model.toLowerCase() !== requested.toLowerCase()) {
1906
+ return { model: check.model, note: `'${requested}' is listed as ${check.model} ${where}; that is the model used.` };
1907
+ }
1908
+ return { model: check.model, note: null };
1909
+ }
1910
+
1911
+ /** settleRequestedModel for a send to `conversation` (its detail payload); an unread catalogue leaves it unchecked. */
1912
+ async function settleSendModel(ctx, conversation, requested) {
1913
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1914
+ const provider = toText(runtime.providerType).toLowerCase() || 'github';
1915
+ let catalog = null;
1916
+ try {
1917
+ catalog = await loadCatalog(ctx);
1918
+ } catch {
1919
+ // No catalogue: the model goes unchecked, and the note says so.
1920
+ }
1921
+ const settings = catalog?.settings?.[provider];
1922
+ return settleRequestedModel(ctx, catalog, provider, requested, {
1923
+ creating: false,
1924
+ extraModels: [
1925
+ ...(Array.isArray(settings?.availableModels) ? settings.availableModels : []),
1926
+ runtime.providerModel,
1927
+ runtime.model,
1928
+ ],
1929
+ });
1679
1930
  }
1680
1931
 
1681
1932
  async function createSession(ctx, args) {
1682
- const catalog = await loadCatalog(ctx);
1933
+ const catalog = ctx.catalog || await loadCatalog(ctx);
1683
1934
  const providers = buildProviders(catalog);
1684
1935
  const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
1685
1936
  const provider = createProvider(ctx.caller, args);
@@ -1694,6 +1945,16 @@ export function createRemoteRelayDispatcher({
1694
1945
  let model = args.model || '';
1695
1946
  let modelSource = model ? 'requested' : '';
1696
1947
  let note = null;
1948
+ let variantEffort = '';
1949
+ if (model) {
1950
+ // Refused here when the provider does not offer it: a session created
1951
+ // with an id its provider never runs stays empty, and every send to it
1952
+ // fails.
1953
+ const settled = settleRequestedModel(ctx, catalog, provider, model, { creating: true });
1954
+ model = settled.model;
1955
+ note = settled.note;
1956
+ variantEffort = normalizeEffort(settled.effort);
1957
+ }
1697
1958
  // A cloud session without a requested model gets the Claude Cloud tab's
1698
1959
  // default, as New Chat does: the bootstrap picks it, nothing is mirrored.
1699
1960
  if (!model && cloud) {
@@ -1715,7 +1976,7 @@ export function createRemoteRelayDispatcher({
1715
1976
 
1716
1977
  // The effort mirrors the caller like the model does: this session's when
1717
1978
  // the remote lists it for the model (or lists nothing), else the remote's.
1718
- let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
1979
+ let choice = { effort: normalizeEffort(args.effort) || variantEffort, source: EFFORT_SOURCES.requested, note: null };
1719
1980
  if (!choice.effort && cloud) {
1720
1981
  // A cloud session takes no effort: none is mirrored, none is reported.
1721
1982
  choice = { effort: '', source: '', note: null };
@@ -1771,6 +2032,11 @@ export function createRemoteRelayDispatcher({
1771
2032
  `Relay "${ctx.relay.name}" did not return the new session's id.`);
1772
2033
  }
1773
2034
  const boundModel = toText(bootstrap?.selectedModel) || model;
2035
+ // A relay that swapped the asked model for another (one that predates
2036
+ // these checks does for Copilot) is named, never passed over in silence.
2037
+ if (modelSource === 'requested' && model && !isAutoModelId(model) && boundModel.toLowerCase() !== model.toLowerCase()) {
2038
+ note = [note, `Relay "${ctx.relay.name}" started the session on ${boundModel}, not on ${model} as asked.`].filter(Boolean).join(' ');
2039
+ }
1774
2040
  let sent;
1775
2041
  try {
1776
2042
  const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
@@ -2272,6 +2538,7 @@ export function createRemoteRelayDispatcher({
2272
2538
  usage: usageAction,
2273
2539
  list_sessions: listSessions,
2274
2540
  read_session: readSession,
2541
+ read_task_output: readTaskOutput,
2275
2542
  wait: waitAction,
2276
2543
  send,
2277
2544
  create_session: createSession,
@@ -2551,10 +2818,39 @@ export function createRemoteRelayDispatcher({
2551
2818
  { relay: relay.name, retryAfterSeconds }));
2552
2819
  }
2553
2820
 
2821
+ // The target's catalogue, loaded before anyone is asked for approval when
2822
+ // the agent names a model: a card for a model that does not exist there
2823
+ // would be a question about nothing. createSession reuses it.
2824
+ let catalog = null;
2825
+ let settledModel = null;
2554
2826
  if (action === 'create_session') {
2555
2827
  const problem = createSessionProblem(caller, args);
2556
2828
  if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
2557
2829
  }
2830
+ if ((action === 'create_session' || action === 'send') && args.model) {
2831
+ const early = { relay, caller, hops, conversationId, signal };
2832
+ try {
2833
+ if (action === 'create_session') {
2834
+ catalog = await loadCatalog(early);
2835
+ const provider = createProvider(caller, args);
2836
+ // A provider not set up there answers as such (createSession), not
2837
+ // with a model it has no list for.
2838
+ if (listProviders(catalog).find((entry) => entry.provider === provider)?.configured) {
2839
+ settleRequestedModel(early, catalog, provider, args.model, { creating: true });
2840
+ }
2841
+ } else {
2842
+ const conversation = await call(early, 'GET', sessionPath(args.session), { query: { limit: 1 } });
2843
+ settledModel = await settleSendModel(early, conversation, args.model);
2844
+ }
2845
+ } catch (error) {
2846
+ if (error instanceof DispatchFailure) {
2847
+ return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
2848
+ }
2849
+ // The target did not answer: the call itself reports that.
2850
+ catalog = null;
2851
+ settledModel = null;
2852
+ }
2853
+ }
2558
2854
 
2559
2855
  const localCreate = local && action === 'create_session';
2560
2856
  let holdsLocalCreate = false;
@@ -2628,7 +2924,7 @@ export function createRemoteRelayDispatcher({
2628
2924
  }
2629
2925
  }
2630
2926
 
2631
- const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
2927
+ const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}) };
2632
2928
  const body = await ACTIONS[action](ctx, args);
2633
2929
  if (prompting) agentPrompts(conversationId).count += 1;
2634
2930
  const { summary, ...rest } = body;