@oar-sh/oar 0.9.4 → 0.9.5

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.
@@ -10,6 +10,10 @@
10
10
  // - stopped: the steer was pushed into a turn the user then stopped, so it
11
11
  // went unanswered (kind='stopped', a marker with Resend).
12
12
  // The steered user message of a fold or stop is still classed as steered.
13
+ // A fourth marker is not a steer's: kind='answered-elsewhere' closes a message
14
+ // whose answer was published on a background-continuation row. It gets the
15
+ // markers' muted look and nothing else — its message was not steered, and the
16
+ // reply next to it is an ordinary one.
13
17
  //
14
18
  // One idempotent DOM pass, same contract as syncTranscriptSeparators: every
15
19
  // insertion path (full render, live append, prepended history page) re-runs it,
@@ -23,11 +27,16 @@ export const STEERED_MSG_CLASS = 'msg-steered';
23
27
  export const STEERED_CONTINUATION_CLASS = 'msg-steered-continuation';
24
28
  export const FOLDED_MSG_CLASS = 'msg-folded';
25
29
  export const STEER_STOPPED_MSG_CLASS = 'msg-steer-stopped';
30
+ export const ANSWERED_ELSEWHERE_MSG_CLASS = 'msg-answered-elsewhere';
26
31
 
27
- /** The assistant row kinds that are settle markers of a steer, not replies. */
32
+ /**
33
+ * The assistant row kinds that are settle markers, not replies. The first two
34
+ * are a steer's (see isSteerMarker); the last only shares their styling.
35
+ */
28
36
  export const STEER_MARKER_CLASS_BY_KIND = Object.freeze({
29
37
  folded: FOLDED_MSG_CLASS,
30
38
  stopped: STEER_STOPPED_MSG_CLASS,
39
+ 'answered-elsewhere': ANSWERED_ELSEWHERE_MSG_CLASS,
31
40
  });
32
41
 
33
42
  function isSteerMarker(node) {
@@ -2812,10 +2812,13 @@
2812
2812
  kind='absorbed' and the steered message carries the continuing reply. A
2813
2813
  steer cut off by Stop settles as kind='stopped' with a Resend button.
2814
2814
  Markers are notes, not answers, so they render muted and compact.
2815
- steered-turn-merge.mjs adds the adjacency classes. */
2815
+ steered-turn-merge.mjs adds the adjacency classes.
2816
+ kind='answered-elsewhere' is no steer's marker but looks like one: it
2817
+ closes a message whose answer is on a background-continuation row. */
2816
2818
  .msg.assistant.msg-absorbed .msg-bubble,
2817
2819
  .msg.assistant.msg-folded .msg-bubble,
2818
- .msg.assistant.msg-steer-stopped .msg-bubble {
2820
+ .msg.assistant.msg-steer-stopped .msg-bubble,
2821
+ .msg.assistant.msg-answered-elsewhere .msg-bubble {
2819
2822
  font-size: var(--fs-meta-md); color: var(--muted); font-style: italic;
2820
2823
  padding: 6px 12px; opacity: 0.85;
2821
2824
  }
@@ -2824,7 +2827,8 @@
2824
2827
  .msg.assistant.msg-folded,
2825
2828
  .msg.assistant.msg-steer-stopped { margin-top: -8px; }
2826
2829
  .msg.assistant.msg-folded .msg-label,
2827
- .msg.assistant.msg-steer-stopped .msg-label { display: none; }
2830
+ .msg.assistant.msg-steer-stopped .msg-label,
2831
+ .msg.assistant.msg-answered-elsewhere .msg-label { display: none; }
2828
2832
  .msg.assistant.msg-steer-stopped .msg-bubble-actions { font-style: normal; }
2829
2833
  .msg.user.msg-steered .msg-bubble { border-style: dashed; }
2830
2834
  .msg.user.msg-steered .msg-label::after { content: ' · steered mid-turn'; }
@@ -37,7 +37,7 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
37
37
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
38
38
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
39
39
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
40
- import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
40
+ import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
41
41
  import { isLiveResend } from '../services/steer-resend.mjs';
42
42
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
43
43
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
@@ -6488,7 +6488,11 @@ export function registerMessagesRoutes(app, deps) {
6488
6488
  });
6489
6489
  });
6490
6490
 
6491
- const SETTLED_STEER_KINDS = new Set(['folded', 'stopped']);
6491
+ // Responses that are settle markers, not replies: stamped with their kind,
6492
+ // and no "turn completed" push. The last one is not a steer's: it closes a
6493
+ // message whose answer was published on a continuation row, and that reply
6494
+ // already sent the push.
6495
+ const SETTLED_STEER_KINDS = new Set(['folded', 'stopped', ANSWERED_ELSEWHERE_KIND]);
6492
6496
 
6493
6497
  // POST /api/response — CLI submits response
6494
6498
  app.post('/api/response', auth, async (req, res) => {
@@ -7013,7 +7017,8 @@ export function registerMessagesRoutes(app, deps) {
7013
7017
  io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
7014
7018
  // An absorbed row is not a finished turn — the reply continues on the
7015
7019
  // steered message's row, whose own completion sends the one notification.
7016
- // A stopped steer has no reply at all.
7020
+ // A stopped steer has no reply at all, and a message answered on a
7021
+ // continuation row was notified by that row.
7017
7022
  if (!settledKind) {
7018
7023
  void pushDispatchService?.notifyTurnComplete?.({
7019
7024
  conversationId: targetConversationId,
@@ -1772,8 +1772,10 @@ export function buildConversationMessages({
1772
1772
  executedProvider: message?.executed_provider || message?.executedProvider || undefined,
1773
1773
  // 'continuation' badges a self-started turn; 'absorbed' marks a
1774
1774
  // reply that continues through the next (steered) user message;
1775
- // 'folded'/'stopped' are a steer's settle markers. Without this the
1776
- // live-appended badge/merge/marker vanished on reload.
1775
+ // 'folded'/'stopped' are a steer's settle markers, and
1776
+ // 'answered-elsewhere' marks a message whose answer is on a
1777
+ // continuation row. Without this the live-appended
1778
+ // badge/merge/marker vanished on reload.
1777
1779
  kind: message?.kind || undefined,
1778
1780
  resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
1779
1781
  resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
@@ -155,10 +155,12 @@ export function createRemoteRelayCallerContext({
155
155
  questionTimeoutMs = DEFAULT_QUESTION_TIMEOUT_MS,
156
156
  } = {}) {
157
157
  const questionStatements = questions || createQuestionRepository(db);
158
- const getConversation = db.prepare(`SELECT id, title, preferred_relay_mode, preferred_model FROM conversations WHERE id = ?`);
158
+ const getConversation = db.prepare(`
159
+ SELECT id, title, preferred_relay_mode, preferred_model, preferred_reasoning_effort FROM conversations WHERE id = ?
160
+ `);
159
161
  const getRuntimeSession = db.prepare(`SELECT provider_type, provider_model, model FROM runtime_sessions WHERE conversation_id = ?`);
160
162
  const listProcessingRows = db.prepare(`
161
- SELECT id, kind, relay_mode, model, attempt_id, owner_sdk_session_id, timestamp, processing_at
163
+ SELECT id, kind, relay_mode, model, reasoning_effort, attempt_id, owner_sdk_session_id, timestamp, processing_at
162
164
  FROM queue
163
165
  WHERE conversation_id = ? AND status = 'processing'
164
166
  ORDER BY COALESCE(processing_at, timestamp) ASC
@@ -217,8 +219,10 @@ export function createRemoteRelayCallerContext({
217
219
  }
218
220
 
219
221
  /**
220
- * `{ conversationId, title, provider, model, mode, processingRowId,
222
+ * `{ conversationId, title, provider, model, effort, mode, processingRowId,
221
223
  * attemptId, userMessageId, hops }` for the conversation making the call.
224
+ * `effort` is the reasoning effort of the live turn, else the one the
225
+ * conversation prefers ('' when neither is known).
222
226
  * `hops` is how many relays the current turn's prompt already crossed: 0
223
227
  * for a human's message. A steered message folded into the live turn is
224
228
  * part of what the agent is acting on, so the highest count among the
@@ -240,6 +244,7 @@ export function createRemoteRelayCallerContext({
240
244
  title: toText(conversation?.title),
241
245
  provider: toText(runtime?.provider_type).toLowerCase() || 'github',
242
246
  model: toText(live?.model) || toText(runtime?.provider_model) || toText(runtime?.model) || toText(conversation?.preferred_model) || '',
247
+ effort: (toText(live?.reasoning_effort) || toText(conversation?.preferred_reasoning_effort)).toLowerCase(),
243
248
  mode: normalizeMode(live?.relay_mode || conversation?.preferred_relay_mode),
244
249
  processingRowId: live?.id || null,
245
250
  attemptId: toText(live?.attempt_id) || null,
@@ -4,6 +4,7 @@ import { randomUUID as nodeRandomUUID } from 'crypto';
4
4
 
5
5
  import {
6
6
  REMOTE_RELAY_APPROVAL_MODES,
7
+ REMOTE_RELAY_EFFORT_PATTERN,
7
8
  REMOTE_RELAY_ERROR_CODES,
8
9
  REMOTE_RELAY_LIMITS,
9
10
  REMOTE_RELAY_PROVIDERS,
@@ -19,6 +20,7 @@ import {
19
20
  withRemotePromptHeader,
20
21
  } from '../../shared/remote-relay-contract.mjs';
21
22
  import { schemaFields } from '../../shared/question-schema.mjs';
23
+ import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
22
24
 
23
25
  // The one place a `remote_relay` tool call is decided and carried out (plan
24
26
  // §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
@@ -93,8 +95,20 @@ const FAILURE_TEXT_PATTERNS = Object.freeze([
93
95
  /^relay timeout after \d+ attempts/i,
94
96
  ]);
95
97
  const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
96
- // Assistant rows that are markers, not replies of their own.
97
- const STUB_KINDS = new Set(['folded', 'stopped']);
98
+ // POST /api/conversation/bootstrap refuses an effort its model does not take
99
+ // with this code (400); POST /api/message sends the same 400 without a code,
100
+ // but with the list below.
101
+ const EFFORT_UNSUPPORTED_CODE = 'REASONING_EFFORT_UNSUPPORTED';
102
+ const EFFORT_SUPPORTED_FIELD = 'supportedReasoningEfforts';
103
+ const EFFORT_SOURCES = Object.freeze({
104
+ requested: 'requested',
105
+ caller: 'same as this session',
106
+ remote: 'remote default',
107
+ });
108
+ // relay_info: efforts per provider, as groups of models that take the same ones.
109
+ const EFFORT_LEVELS_MAX = 12;
110
+ const EFFORT_GROUPS_MAX = 8;
111
+ const EFFORT_GROUP_MODELS_MAX = 40;
98
112
 
99
113
  function toText(value) {
100
114
  return String(value ?? '').trim();
@@ -214,6 +228,73 @@ function matchOfferedModel(candidate, offered = []) {
214
228
  return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
215
229
  }
216
230
 
231
+ /** A reasoning effort as the relays write it, or '' when the value is not one. */
232
+ function normalizeEffort(value) {
233
+ const effort = toText(value).toLowerCase();
234
+ return REMOTE_RELAY_EFFORT_PATTERN.test(effort) ? effort : '';
235
+ }
236
+
237
+ function effortLevels(values) {
238
+ return uniqueTexts((Array.isArray(values) ? values : []).map(normalizeEffort)).slice(0, EFFORT_LEVELS_MAX);
239
+ }
240
+
241
+ /**
242
+ * The efforts /api/models lists for a provider's model, or null when the
243
+ * remote does not say. `reasoningByProvider` is the provider's own view;
244
+ * `reasoningByModel` is keyed by model id alone, which providers share, so it
245
+ * only decides where the provider has no entry of its own.
246
+ */
247
+ function catalogEffortsFor(models, provider, model) {
248
+ const key = toText(model).toLowerCase();
249
+ if (!key || !isPlainObject(models)) return null;
250
+ const keys = uniqueTexts([key, key.replace(CLAUDE_LONG_CONTEXT_SUFFIX, '')]);
251
+ const maps = [
252
+ isPlainObject(models.reasoningByProvider) ? models.reasoningByProvider[provider] : null,
253
+ models.reasoningByModel,
254
+ ];
255
+ for (const map of maps) {
256
+ if (!isPlainObject(map)) continue;
257
+ for (const candidate of keys) {
258
+ const levels = effortLevels(map[candidate]);
259
+ if (levels.length) return levels;
260
+ }
261
+ }
262
+ return null;
263
+ }
264
+
265
+ /** `[{ efforts, models }]`: the models of a provider grouped by the efforts they take. */
266
+ function groupEffortsByModel(models, provider, offered = []) {
267
+ const groups = new Map();
268
+ for (const model of offered) {
269
+ const levels = catalogEffortsFor(models, provider, model);
270
+ if (!levels) continue;
271
+ const id = levels.join(' ');
272
+ if (!groups.has(id)) groups.set(id, { efforts: levels, models: [] });
273
+ groups.get(id).models.push(model);
274
+ }
275
+ return [...groups.values()]
276
+ .sort((a, b) => b.models.length - a.models.length)
277
+ .slice(0, EFFORT_GROUPS_MAX)
278
+ .map((group) => (group.models.length > EFFORT_GROUP_MODELS_MAX
279
+ ? { efforts: group.efforts, models: group.models.slice(0, EFFORT_GROUP_MODELS_MAX), more: group.models.length - EFFORT_GROUP_MODELS_MAX }
280
+ : group));
281
+ }
282
+
283
+ /** True when the remote answered 400 because its model does not take the effort we sent. */
284
+ function isEffortRejection(error) {
285
+ const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
286
+ if (!body || Number(error?.status) !== 400) return false;
287
+ return toText(body.code) === EFFORT_UNSUPPORTED_CODE || Array.isArray(body[EFFORT_SUPPORTED_FIELD]);
288
+ }
289
+
290
+ function supportedEffortsOf(error) {
291
+ return effortLevels(error?.remoteBody?.[EFFORT_SUPPORTED_FIELD]);
292
+ }
293
+
294
+ function supportedEffortsText(levels) {
295
+ return levels.length ? ` (it takes: ${levels.join(', ')})` : '';
296
+ }
297
+
217
298
  function isRemoteRelayError(error) {
218
299
  return error?.name === 'RemoteRelayError' || /^REMOTE_/.test(toText(error?.code));
219
300
  }
@@ -333,7 +414,7 @@ const FINISHED_TASK_STATUSES = new Set(['completed', 'complete', 'done', 'failed
333
414
  * read they came with. A later user message ends the follow-up: from there
334
415
  * the session is working for someone else.
335
416
  */
336
- export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundWork = false } = {}) {
417
+ export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundWork = false, sentMessageId = '' } = {}) {
337
418
  const list = Array.isArray(rows) ? rows : [];
338
419
  const start = list.findIndex((row) => toText(row?.id) === replyMessageId);
339
420
  const afterReply = start === -1 ? [] : list.slice(start + 1);
@@ -346,7 +427,13 @@ export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundW
346
427
  // A queued follow-up turn shows only as activeTurn (between a task's end and
347
428
  // the follow-up turn starting); on its own, activeTurn is too weak a sign, so
348
429
  // it counts once background work or a follow-up was seen for this message.
349
- const backgroundSeen = sawBackgroundWork || tasks.length > 0 || followUps.length > 0;
430
+ // A turn in flight that belongs to no user message (not the sent one, and no
431
+ // later one exists) is a turn the agent opened itself: a follow-up that is
432
+ // running right now, whether or not a background task was ever published
433
+ // (subagents the agent waits for are not).
434
+ const turnInFlight = isPlainObject(page?.inFlight) ? toText(page.inFlight.messageId) : '';
435
+ const ownTurnInFlight = !!turnInFlight && !handedOver && !!sentMessageId && turnInFlight !== sentMessageId;
436
+ const backgroundSeen = sawBackgroundWork || tasks.length > 0 || followUps.length > 0 || ownTurnInFlight;
350
437
  const working = !handedOver && (
351
438
  tasks.length > 0
352
439
  || (isPlainObject(page?.inFlight) && backgroundSeen)
@@ -395,6 +482,25 @@ export function settleRemoteReply(messages, messageId) {
395
482
  if (looksLikeFailureText(answer.text)) return { status: 'failed', reply: replyOf(answer, { folded: true }), note };
396
483
  return { status: 'done', reply: replyOf(answer, { folded: true }), note };
397
484
  }
485
+ if (kind === ANSWERED_ELSEWHERE_KIND) {
486
+ // The marker only points at the answer, which the remote published as a
487
+ // background turn BEFORE it closed the message: the nearest continuation
488
+ // reply above the marker. The marker's own text is never the reply — it
489
+ // tells a human to resend, and a calling agent would run the prompt twice.
490
+ const answer = rows.slice(0, rows.indexOf(own)).reverse().find((row) => row?.role === 'assistant'
491
+ && toText(row.kind).toLowerCase() === 'continuation');
492
+ if (!answer) {
493
+ return {
494
+ status: 'done',
495
+ note: 'The remote agent answered this message in a background turn, but that reply was not found after the message; use read_session to see it. Do not send the message again.',
496
+ };
497
+ }
498
+ const note = 'The remote relay published the answer to this message as a background turn; this is that turn\'s reply.';
499
+ if (looksLikeFailureText(answer.text)) {
500
+ return { status: 'failed', reply: replyOf(answer, { answeredElsewhere: true }), note };
501
+ }
502
+ return { status: 'done', reply: replyOf(answer, { answeredElsewhere: true }), note };
503
+ }
398
504
  if (looksLikeFailureText(own.text)) {
399
505
  return { status: 'failed', reply: replyOf(own), note: 'The remote turn failed; the reply is the relay\'s failure text.' };
400
506
  }
@@ -638,7 +744,7 @@ export function createRemoteRelayDispatcher({
638
744
  const settled = settleRemoteReply(after.rows, messageId);
639
745
  if (settled && !settled.foldedWithoutReply) {
640
746
  if (settled.status !== 'done' || !settled.reply?.messageId) return { ...base, ...settled };
641
- const follow = remoteFollowUpState(after.rows, settled.reply.messageId, page, { sawBackgroundWork: seen.background === true });
747
+ const follow = remoteFollowUpState(after.rows, settled.reply.messageId, page, { sawBackgroundWork: seen.background === true, sentMessageId: messageId });
642
748
  if (follow.backgroundSeen) seen.background = true;
643
749
  const latestReply = follow.latest ? replyOf(follow.latest, { followUp: true }) : settled.reply;
644
750
  if (!follow.working) {
@@ -766,8 +872,18 @@ export function createRemoteRelayDispatcher({
766
872
  };
767
873
  }
768
874
 
769
- /** `[{ provider, configured, defaultModel, models }]` in contract order. */
875
+ /**
876
+ * `[{ provider, configured, defaultModel, models }]` in contract order, with
877
+ * `efforts` where the remote lists the reasoning efforts of those models.
878
+ */
770
879
  function buildProviders(catalog) {
880
+ return listProviders(catalog).map((entry) => {
881
+ const efforts = groupEffortsByModel(catalog.models, entry.provider, entry.models);
882
+ return efforts.length ? { ...entry, efforts } : entry;
883
+ });
884
+ }
885
+
886
+ function listProviders(catalog) {
771
887
  const models = catalog.models || {};
772
888
  const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
773
889
  const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
@@ -1055,7 +1171,7 @@ export function createRemoteRelayDispatcher({
1055
1171
  * When the replay fails too, nobody knows; the error carries the session
1056
1172
  * and message id so the agent checks with wait instead of sending again.
1057
1173
  */
1058
- async function postPrompt(ctx, { session, text, origin, model, mode }) {
1174
+ async function postPrompt(ctx, { session, text, origin, model, mode, effort }) {
1059
1175
  const messageId = String(randomUUID());
1060
1176
  const body = {
1061
1177
  conversationId: session,
@@ -1064,6 +1180,7 @@ export function createRemoteRelayDispatcher({
1064
1180
  origin,
1065
1181
  ...(model ? { model } : {}),
1066
1182
  ...(mode ? { relayMode: mode } : {}),
1183
+ ...(effort ? { reasoningEffort: effort } : {}),
1067
1184
  };
1068
1185
  let sent;
1069
1186
  try {
@@ -1090,11 +1207,72 @@ export function createRemoteRelayDispatcher({
1090
1207
  messageId: toText(sent.duplicateOfMessageId) || null,
1091
1208
  };
1092
1209
  }
1093
- return { duplicate: false, messageId: toText(sent?.messageId) || messageId };
1210
+ return {
1211
+ duplicate: false,
1212
+ messageId: toText(sent?.messageId) || messageId,
1213
+ effort: normalizeEffort(sent?.selectedReasoningEffort),
1214
+ };
1215
+ }
1216
+
1217
+ /**
1218
+ * What the remote made of the effort: `reported` is the one it bound (the
1219
+ * bootstrap's preferredReasoningEffort, the message's
1220
+ * selectedReasoningEffort). Most providers do not refuse an effort their
1221
+ * model lacks, they fall back to their default, so the result names the
1222
+ * effort the turn runs with and says when that is not the one asked for.
1223
+ */
1224
+ function settleEffort(ctx, choice, reported) {
1225
+ const bound = normalizeEffort(reported);
1226
+ if (!bound || bound === choice.effort) return choice;
1227
+ if (!choice.effort) return { ...choice, effort: bound, source: EFFORT_SOURCES.remote };
1228
+ return {
1229
+ effort: bound,
1230
+ source: EFFORT_SOURCES.remote,
1231
+ note: choice.note
1232
+ || `Relay "${ctx.relay.name}" does not offer effort "${choice.effort}" for this model; the turn runs with "${bound}".`,
1233
+ };
1234
+ }
1235
+
1236
+ /**
1237
+ * Queues the prompt with the chosen effort. An effort the agent did not ask
1238
+ * for (this session's, the remote session's own) that the remote refuses is
1239
+ * dropped and the prompt sent once more without it; one the agent asked for
1240
+ * is the remote's to refuse, so that error goes back as it is.
1241
+ */
1242
+ async function postPromptWithEffort(ctx, prompt, choice) {
1243
+ try {
1244
+ const sent = await postPrompt(ctx, { ...prompt, effort: choice.effort });
1245
+ return { sent, choice: settleEffort(ctx, choice, sent.effort) };
1246
+ } catch (error) {
1247
+ if (!choice.effort || choice.source === EFFORT_SOURCES.requested || !isEffortRejection(error)) throw error;
1248
+ const dropped = {
1249
+ effort: '',
1250
+ source: EFFORT_SOURCES.remote,
1251
+ note: `Relay "${ctx.relay.name}" refused effort "${choice.effort}"${supportedEffortsText(supportedEffortsOf(error))}; sent again without it, so the turn runs with the remote's default.`,
1252
+ };
1253
+ const sent = await postPrompt(ctx, { ...prompt, effort: '' });
1254
+ return { sent, choice: settleEffort(ctx, dropped, sent.effort) };
1255
+ }
1256
+ }
1257
+
1258
+ /** The `effort` / `effortSource` fields of a result, and the note that goes with them. */
1259
+ function effortResult(choice) {
1260
+ return {
1261
+ ...(choice.source ? { effort: choice.effort || null, effortSource: choice.source } : {}),
1262
+ ...(choice.note ? { note: choice.note } : {}),
1263
+ };
1094
1264
  }
1095
1265
 
1096
- async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra = {} }) {
1266
+ async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra: given = {} }) {
1097
1267
  const openUrl = remoteConversationUrl(ctx.relay.url, session);
1268
+ // `extra.note` (how the effort was settled) goes in front of the outcome's own.
1269
+ const { note: extraNote, ...extra } = given;
1270
+ const result = await deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl });
1271
+ const note = [extraNote, result.note].map(toText).filter(Boolean).join(' ');
1272
+ return note ? { ...result, note } : result;
1273
+ }
1274
+
1275
+ async function deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl }) {
1098
1276
  if (sent.duplicate) {
1099
1277
  const outcome = {
1100
1278
  status: 'duplicate',
@@ -1128,9 +1306,18 @@ export function createRemoteRelayDispatcher({
1128
1306
  const model = args.model
1129
1307
  || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
1130
1308
  const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
1309
+ // The remote runs a prompt that names no effort with its provider's
1310
+ // default, not with the one the session is set to: pass that one on, so
1311
+ // the session keeps its own. This session's effort is never mirrored here.
1312
+ const requested = normalizeEffort(args.effort);
1313
+ const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
1131
1314
  const origin = await buildOrigin(ctx);
1132
- const sent = await postPrompt(ctx, { session, text: args.text, origin, model, mode });
1133
- return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds });
1315
+ const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
1316
+ effort: requested || own,
1317
+ source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
1318
+ note: null,
1319
+ });
1320
+ return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds, extra: effortResult(choice) });
1134
1321
  }
1135
1322
 
1136
1323
  async function createSession(ctx, args) {
@@ -1163,23 +1350,75 @@ export function createRemoteRelayDispatcher({
1163
1350
  const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
1164
1351
  const origin = await buildOrigin(ctx);
1165
1352
 
1166
- const bootstrap = await call(ctx, 'POST', '/api/conversation/bootstrap', {
1353
+ // The effort mirrors the caller like the model does: this session's when
1354
+ // the remote lists it for the model (or lists nothing), else the remote's.
1355
+ let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
1356
+ if (!choice.effort) {
1357
+ const callerEffort = normalizeEffort(ctx.caller.effort);
1358
+ const offered = catalogEffortsFor(catalog.models, provider, model);
1359
+ if (callerEffort && (!offered || offered.includes(callerEffort))) {
1360
+ choice = { effort: callerEffort, source: EFFORT_SOURCES.caller, note: null };
1361
+ } else {
1362
+ choice = {
1363
+ effort: '',
1364
+ source: EFFORT_SOURCES.remote,
1365
+ note: callerEffort
1366
+ ? `${model || provider} on relay "${ctx.relay.name}" does not offer this session's effort "${callerEffort}"${supportedEffortsText(offered || [])}; the session uses the remote's default.`
1367
+ : null,
1368
+ };
1369
+ }
1370
+ }
1371
+
1372
+ const bootstrapWith = (effort) => call(ctx, 'POST', '/api/conversation/bootstrap', {
1167
1373
  body: {
1168
1374
  providerType: provider,
1169
1375
  ...(model ? { model } : {}),
1170
1376
  ...(mode ? { relayMode: mode } : {}),
1377
+ ...(effort ? { reasoningEffort: effort } : {}),
1171
1378
  title,
1172
1379
  ...(args.cwd ? { cwd: args.cwd } : {}),
1173
1380
  origin,
1174
1381
  },
1175
1382
  });
1383
+ let bootstrap;
1384
+ try {
1385
+ bootstrap = await bootstrapWith(choice.effort);
1386
+ } catch (error) {
1387
+ // Only an effort the agent did not ask for is dropped; a refused
1388
+ // bootstrap created nothing, so asking again is safe.
1389
+ if (!choice.effort || choice.source === EFFORT_SOURCES.requested || !isEffortRejection(error)) throw error;
1390
+ choice = {
1391
+ effort: '',
1392
+ source: EFFORT_SOURCES.remote,
1393
+ note: `Relay "${ctx.relay.name}" refused this session's effort "${choice.effort}"${supportedEffortsText(supportedEffortsOf(error))}; the session was started without it and uses the remote's default.`,
1394
+ };
1395
+ bootstrap = await bootstrapWith('');
1396
+ }
1397
+ choice = settleEffort(ctx, choice, bootstrap?.preferredReasoningEffort);
1176
1398
  const session = toText(bootstrap?.conversationId);
1177
1399
  if (!session) {
1178
1400
  throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
1179
1401
  `Relay "${ctx.relay.name}" did not return the new session's id.`);
1180
1402
  }
1181
1403
  const boundModel = toText(bootstrap?.selectedModel) || model;
1182
- const sent = await postPrompt(ctx, { session, text: args.text, origin, model: boundModel, mode });
1404
+ let sent;
1405
+ try {
1406
+ ({ sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice));
1407
+ } catch (error) {
1408
+ if (!choice.effort || !isEffortRejection(error)) throw error;
1409
+ // The session exists by now: say so, or the agent starts a second one.
1410
+ const supported = supportedEffortsOf(error);
1411
+ const remoteCode = toText(error.remoteBody?.code);
1412
+ throw new DispatchFailure(400, toText(error.code) || `${REMOTE_RELAY_ERROR_CODES.httpPrefix}400`,
1413
+ `${toText(error.message) || `Relay "${ctx.relay.name}" refused effort "${choice.effort}"`}. The session was created, but the prompt was not queued.`,
1414
+ {
1415
+ session,
1416
+ ...(remoteCode ? { remoteCode } : {}),
1417
+ ...(supported.length ? { supportedEfforts: supported } : {}),
1418
+ openUrl: remoteConversationUrl(ctx.relay.url, session),
1419
+ note: 'Use send with this session and an effort it takes (or none); do not create another session.',
1420
+ });
1421
+ }
1183
1422
  return deliverAndWait('create_session', ctx, {
1184
1423
  session,
1185
1424
  sent,
@@ -1192,6 +1431,7 @@ export function createRemoteRelayDispatcher({
1192
1431
  title,
1193
1432
  ...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
1194
1433
  ...(note ? { modelNote: note } : {}),
1434
+ ...effortResult(choice),
1195
1435
  },
1196
1436
  });
1197
1437
  }
@@ -1332,10 +1572,13 @@ export function createRemoteRelayDispatcher({
1332
1572
  }
1333
1573
  const remoteCode = toText(remoteBody?.code) || null;
1334
1574
  const supportedModels = Array.isArray(remoteBody?.supportedModels) ? remoteBody.supportedModels.slice(0, 50) : null;
1575
+ // A refused effort: the ones the remote's model takes, so the agent can pick one.
1576
+ const supportedEfforts = isEffortRejection(error) ? supportedEffortsOf(error) : [];
1335
1577
  const details = {
1336
1578
  ...extra,
1337
1579
  ...(remoteCode ? { remoteCode } : {}),
1338
1580
  ...(supportedModels ? { supportedModels } : {}),
1581
+ ...(supportedEfforts.length ? { supportedEfforts } : {}),
1339
1582
  };
1340
1583
  if (code.startsWith(C.httpPrefix) && status >= 400 && status < 500) return failure(status, code, message, details);
1341
1584
  return failure(502, code || `${C.httpPrefix}502`, message, details);
@@ -13,6 +13,7 @@ import {
13
13
  buildOarMcpServerLaunch,
14
14
  isOarMcpServerDisabled,
15
15
  } from '../mcp/oar-mcp-server.mjs';
16
+ import { WORKER_LOG_FILE_ENV } from '../../shared/worker-runtime/worker-log-file.mjs';
16
17
 
17
18
  export { isOarMcpServerDisabled };
18
19
 
@@ -507,7 +508,8 @@ export function createWorkerSecretEnvFile(env = {}, {
507
508
  * their output discarded (`stdio: 'ignore'` / bare tmux exec), so a crash
508
509
  * left zero forensic trail — the failure mode that made the 2026-08-11
509
510
  * incident undiagnosable. Best-effort by design: a log problem must never
510
- * block a worker spawn. Naive rotation: >10 MB rolls to `<file>.1`.
511
+ * block a worker spawn. Naive rotation: >10 MB rolls to `<file>.1`, checked
512
+ * at each launch on every platform.
511
513
  */
512
514
  function resolveWorkerLogDir(launchEnv = {}, { pathImpl = path } = {}) {
513
515
  const explicitDir = normalizeText(launchEnv?.COPILOT_WEB_RELAY_LOG_DIR);
@@ -683,6 +685,9 @@ export async function launchSessionCli({
683
685
  // Extension engine: writes the session's OAR MCP config, returns its path
684
686
  // (or null to launch without it).
685
687
  prepareOarMcpConfigImpl = prepareOarMcpConfigFile,
688
+ // Windows console launch: resolves (and rotates) the worker log the worker
689
+ // then writes itself; returns its path or null.
690
+ prepareWorkerLogFileImpl = prepareWorkerLogFile,
686
691
  } = {}) {
687
692
  const target = String(targetSessionId || '').trim();
688
693
  if (!target) throw new Error('missing-target-session-id');
@@ -719,6 +724,9 @@ export async function launchSessionCli({
719
724
  ...launchEnv,
720
725
  SESSION_ID: target,
721
726
  };
727
+ // Set per launch below, never inherited: a relay started from inside a
728
+ // worker session would otherwise hand its workers that session's log.
729
+ delete launchSessionEnv[WORKER_LOG_FILE_ENV];
722
730
  // The extension engine gets the OAR MCP server (remote_relay + preview) the
723
731
  // node workers carry in-process; every launch path appends the same args.
724
732
  const extensionCliArgs = resolveNodeWorkerDescriptor(launchSessionEnv)
@@ -842,8 +850,15 @@ export async function launchSessionCli({
842
850
  : ['--allow-all', '--session-id', target, ...extensionCliArgs]);
843
851
  // POSIX node workers tee stdout/stderr into the worker log (a crash must
844
852
  // leave a trail). On win32 the `start` intermediary opens its own console,
845
- // so fd inheritance cannot reach the worker — its window shows the output.
853
+ // so fd inheritance cannot reach the worker, and a shell redirect would
854
+ // leave its window empty: the worker is told the file and copies its own
855
+ // output into it (shared/worker-runtime/worker-log-file.mjs). Only the path
856
+ // travels; nothing of the environment or command line is written to the log.
846
857
  let detachedStdio = 'ignore';
858
+ if (platform === 'win32' && nodeWorker) {
859
+ const workerLogPath = prepareWorkerLogFileImpl(target, launchSessionEnv);
860
+ if (workerLogPath) launchSessionEnv[WORKER_LOG_FILE_ENV] = workerLogPath;
861
+ }
847
862
  if (platform !== 'win32' && nodeWorker) {
848
863
  const workerLogPath = prepareWorkerLogFile(target, launchSessionEnv);
849
864
  if (workerLogPath) {
@@ -60,6 +60,10 @@ export const REMOTE_RELAY_SESSION_SCOPES = Object.freeze(['active', 'recent', 'a
60
60
  export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok']);
61
61
  export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
62
62
  export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
63
+ // Reasoning efforts differ per provider and model (none, low, medium, high,
64
+ // xhigh, max, ultracode, ...), so the contract only checks the shape of the
65
+ // value: the remote relay decides whether its model takes it.
66
+ export const REMOTE_RELAY_EFFORT_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
63
67
 
64
68
  export const REMOTE_RELAY_RESULT_STATUSES = Object.freeze([
65
69
  'queued',
@@ -203,6 +207,12 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
203
207
  enum: [...REMOTE_RELAY_MODES],
204
208
  description: 'send / create_session: relay mode for the remote turn (default: this session\'s mode).',
205
209
  },
210
+ effort: {
211
+ type: 'string',
212
+ description: 'send / create_session: reasoning effort for the remote turn, e.g. low, medium, high (relay_info lists '
213
+ + 'what each model takes). create_session default: this session\'s effort when the remote model supports it, '
214
+ + 'else the remote\'s default. send default: the remote session keeps its own.',
215
+ },
206
216
  title: { type: 'string', description: 'create_session: session title (default: from the prompt).' },
207
217
  question_id: { type: 'string', description: 'answer_question: id from pendingQuestions.' },
208
218
  answer: { type: 'string', description: 'answer_question: free-text answer.' },
@@ -312,6 +322,13 @@ export function validateRemoteRelayToolInput(input = {}) {
312
322
  optionalText('model', 200);
313
323
  const modeCheck = optionalEnum('mode', REMOTE_RELAY_MODES);
314
324
  if (!modeCheck.ok) return modeCheck;
325
+ const effort = toText(input?.effort).toLowerCase();
326
+ if (effort) {
327
+ if (!REMOTE_RELAY_EFFORT_PATTERN.test(effort)) {
328
+ return invalid('effort must be a short word such as low, medium or high (letters, digits and dashes, at most 32 characters)');
329
+ }
330
+ args.effort = effort;
331
+ }
315
332
  if (action === 'send') {
316
333
  const busyCheck = optionalEnum('if_busy', REMOTE_RELAY_IF_BUSY);
317
334
  if (!busyCheck.ok) return busyCheck;
@@ -41,6 +41,12 @@ const VARIANTS = Object.freeze({
41
41
  message: `This message was sent to ${label} just before you stopped the turn, so it was not answered.`,
42
42
  guidance: 'Resend it if you still want an answer.',
43
43
  }),
44
+ // Not a steer: delivered between turns and answered on a background
45
+ // continuation row (ANSWERED_ELSEWHERE_KIND in steer-settle-markers.mjs).
46
+ answeredElsewhere: (label) => ({
47
+ message: `${sentenceCase(label)} answered this message in a reply marked “background continuation” — check the replies next to it.`,
48
+ guidance: 'Resend it only if it went unanswered.',
49
+ }),
44
50
  // Pulled back out of the runtime's queue by the user (un-steer) — the
45
51
  // prompt never ran — but the relay could not be told in time.
46
52
  cancelled: (label) => ({