@oar-sh/oar 0.9.4 → 0.9.6

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 (71) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +199 -0
  4. package/README.md +35 -6
  5. package/package.json +2 -1
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  7. package/server/claude-worker/claude-session-process.mjs +411 -19
  8. package/server/claude-worker/claude-session-worker.mjs +5 -0
  9. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  10. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  11. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  12. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  13. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  14. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  15. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  17. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  19. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  20. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  21. package/server/db-schema.mjs +7 -0
  22. package/server/grok-worker/acp-client.mjs +68 -12
  23. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  24. package/server/grok-worker/grok-session-worker.mjs +5 -0
  25. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  26. package/server/public/app/api-client.js +17 -2
  27. package/server/public/app/bootstrap.js +3 -0
  28. package/server/public/app/composer-control-state.mjs +4 -3
  29. package/server/public/app/conversation-view.js +97 -4
  30. package/server/public/app/journal-view.js +6 -0
  31. package/server/public/app/markdown-strikethrough.mjs +31 -0
  32. package/server/public/app/relay-board-inline.mjs +70 -0
  33. package/server/public/app/relay-board-view.js +157 -61
  34. package/server/public/app/relay-error-ctas.mjs +12 -3
  35. package/server/public/app/socket-handlers.js +9 -0
  36. package/server/public/app/steered-turn-merge.mjs +10 -1
  37. package/server/public/app/store.js +4 -1
  38. package/server/public/app/subagent-fold.mjs +61 -0
  39. package/server/public/app/usage-limit-ui.js +225 -0
  40. package/server/public/index.html +97 -4
  41. package/server/repositories/message-repository.mjs +10 -1
  42. package/server/repositories/question-repository.mjs +1 -0
  43. package/server/repositories/remote-relay-repository.mjs +30 -0
  44. package/server/routes/messages-routes.mjs +93 -17
  45. package/server/routes/relay-board-routes.mjs +56 -2
  46. package/server/routes/sessions-routes.mjs +40 -2
  47. package/server/server-runtime.mjs +33 -2
  48. package/server/services/preview-proxy-server.mjs +64 -36
  49. package/server/services/process-list-service.mjs +122 -0
  50. package/server/services/relay-console-log.mjs +199 -0
  51. package/server/services/remote-relay-caller-context.mjs +45 -20
  52. package/server/services/remote-relay-dispatcher.mjs +328 -21
  53. package/server/services/remote-relay-inbound.mjs +35 -0
  54. package/server/services/session-worker-launch-service.mjs +17 -2
  55. package/server/services/session-worker-process-service.mjs +85 -32
  56. package/server/services/session-worker-registry-service.mjs +1 -1
  57. package/server/services/session-worker-supervisor-service.mjs +33 -1
  58. package/server/services/usage-limit-pause-service.mjs +441 -0
  59. package/shared/ask-user-bridge.mjs +14 -39
  60. package/shared/claude-usage-limit.mjs +257 -0
  61. package/shared/control-poller.mjs +69 -20
  62. package/shared/failure-note-text.mjs +38 -0
  63. package/shared/question-wait.mjs +108 -0
  64. package/shared/remote-relay-contract.mjs +33 -2
  65. package/shared/steer-settle-failure.mjs +22 -0
  66. package/shared/steer-settle-markers.mjs +24 -0
  67. package/shared/worker-runtime/process-tree.mjs +483 -0
  68. package/shared/worker-runtime/relay-errors.mjs +31 -0
  69. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  70. package/shared/worker-runtime/turn-liveness.mjs +249 -0
  71. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -2,12 +2,15 @@
2
2
 
3
3
  import { randomUUID as nodeRandomUUID } from 'crypto';
4
4
 
5
+ import { isFailureNoteText } from '../../shared/failure-note-text.mjs';
5
6
  import {
6
7
  REMOTE_RELAY_APPROVAL_MODES,
8
+ REMOTE_RELAY_EFFORT_PATTERN,
7
9
  REMOTE_RELAY_ERROR_CODES,
8
10
  REMOTE_RELAY_LIMITS,
9
11
  REMOTE_RELAY_PROVIDERS,
10
12
  isRemoteRelayApprovalQuestion,
13
+ isRemoteRelayPromptAction,
11
14
  isRemoteRelayWriteAction,
12
15
  normalizeRemoteRelayOrigin,
13
16
  remoteConversationUrl,
@@ -19,6 +22,7 @@ import {
19
22
  withRemotePromptHeader,
20
23
  } from '../../shared/remote-relay-contract.mjs';
21
24
  import { schemaFields } from '../../shared/question-schema.mjs';
25
+ import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
22
26
 
23
27
  // The one place a `remote_relay` tool call is decided and carried out (plan
24
28
  // §5.6). Every provider adapter ends up at POST /api/remote-relays/tool, whose
@@ -88,13 +92,24 @@ const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
88
92
  // marker counts only there: a normal reply that quotes a relay error further
89
93
  // down is a reply, not a failure.
90
94
  const FAILURE_TEXT_PATTERNS = Object.freeze([
91
- /^(?:(?!\n[ \t]*\r?\n)[\s\S]){0,600}?\berror code:\s*relay\.[a-z0-9-]+\.(?=\s|$)/i,
92
95
  /^relay recovery limit reached after \d+ attempts/i,
93
96
  /^relay timeout after \d+ attempts/i,
94
97
  ]);
95
98
  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']);
99
+ // POST /api/conversation/bootstrap refuses an effort its model does not take
100
+ // with this code (400); POST /api/message sends the same 400 without a code,
101
+ // but with the list below.
102
+ const EFFORT_UNSUPPORTED_CODE = 'REASONING_EFFORT_UNSUPPORTED';
103
+ const EFFORT_SUPPORTED_FIELD = 'supportedReasoningEfforts';
104
+ const EFFORT_SOURCES = Object.freeze({
105
+ requested: 'requested',
106
+ caller: 'same as this session',
107
+ remote: 'remote default',
108
+ });
109
+ // relay_info: efforts per provider, as groups of models that take the same ones.
110
+ const EFFORT_LEVELS_MAX = 12;
111
+ const EFFORT_GROUPS_MAX = 8;
112
+ const EFFORT_GROUP_MODELS_MAX = 40;
98
113
 
99
114
  function toText(value) {
100
115
  return String(value ?? '').trim();
@@ -170,7 +185,7 @@ function compactQuery(query = {}) {
170
185
 
171
186
  function looksLikeFailureText(text) {
172
187
  const value = toText(text);
173
- return FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
188
+ return isFailureNoteText(value) || FAILURE_TEXT_PATTERNS.some((pattern) => pattern.test(value));
174
189
  }
175
190
 
176
191
  function normalizeProvider(value) {
@@ -214,6 +229,73 @@ function matchOfferedModel(candidate, offered = []) {
214
229
  return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
215
230
  }
216
231
 
232
+ /** A reasoning effort as the relays write it, or '' when the value is not one. */
233
+ function normalizeEffort(value) {
234
+ const effort = toText(value).toLowerCase();
235
+ return REMOTE_RELAY_EFFORT_PATTERN.test(effort) ? effort : '';
236
+ }
237
+
238
+ function effortLevels(values) {
239
+ return uniqueTexts((Array.isArray(values) ? values : []).map(normalizeEffort)).slice(0, EFFORT_LEVELS_MAX);
240
+ }
241
+
242
+ /**
243
+ * The efforts /api/models lists for a provider's model, or null when the
244
+ * remote does not say. `reasoningByProvider` is the provider's own view;
245
+ * `reasoningByModel` is keyed by model id alone, which providers share, so it
246
+ * only decides where the provider has no entry of its own.
247
+ */
248
+ function catalogEffortsFor(models, provider, model) {
249
+ const key = toText(model).toLowerCase();
250
+ if (!key || !isPlainObject(models)) return null;
251
+ const keys = uniqueTexts([key, key.replace(CLAUDE_LONG_CONTEXT_SUFFIX, '')]);
252
+ const maps = [
253
+ isPlainObject(models.reasoningByProvider) ? models.reasoningByProvider[provider] : null,
254
+ models.reasoningByModel,
255
+ ];
256
+ for (const map of maps) {
257
+ if (!isPlainObject(map)) continue;
258
+ for (const candidate of keys) {
259
+ const levels = effortLevels(map[candidate]);
260
+ if (levels.length) return levels;
261
+ }
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /** `[{ efforts, models }]`: the models of a provider grouped by the efforts they take. */
267
+ function groupEffortsByModel(models, provider, offered = []) {
268
+ const groups = new Map();
269
+ for (const model of offered) {
270
+ const levels = catalogEffortsFor(models, provider, model);
271
+ if (!levels) continue;
272
+ const id = levels.join(' ');
273
+ if (!groups.has(id)) groups.set(id, { efforts: levels, models: [] });
274
+ groups.get(id).models.push(model);
275
+ }
276
+ return [...groups.values()]
277
+ .sort((a, b) => b.models.length - a.models.length)
278
+ .slice(0, EFFORT_GROUPS_MAX)
279
+ .map((group) => (group.models.length > EFFORT_GROUP_MODELS_MAX
280
+ ? { efforts: group.efforts, models: group.models.slice(0, EFFORT_GROUP_MODELS_MAX), more: group.models.length - EFFORT_GROUP_MODELS_MAX }
281
+ : group));
282
+ }
283
+
284
+ /** True when the remote answered 400 because its model does not take the effort we sent. */
285
+ function isEffortRejection(error) {
286
+ const body = isPlainObject(error?.remoteBody) ? error.remoteBody : null;
287
+ if (!body || Number(error?.status) !== 400) return false;
288
+ return toText(body.code) === EFFORT_UNSUPPORTED_CODE || Array.isArray(body[EFFORT_SUPPORTED_FIELD]);
289
+ }
290
+
291
+ function supportedEffortsOf(error) {
292
+ return effortLevels(error?.remoteBody?.[EFFORT_SUPPORTED_FIELD]);
293
+ }
294
+
295
+ function supportedEffortsText(levels) {
296
+ return levels.length ? ` (it takes: ${levels.join(', ')})` : '';
297
+ }
298
+
217
299
  function isRemoteRelayError(error) {
218
300
  return error?.name === 'RemoteRelayError' || /^REMOTE_/.test(toText(error?.code));
219
301
  }
@@ -333,7 +415,7 @@ const FINISHED_TASK_STATUSES = new Set(['completed', 'complete', 'done', 'failed
333
415
  * read they came with. A later user message ends the follow-up: from there
334
416
  * the session is working for someone else.
335
417
  */
336
- export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundWork = false } = {}) {
418
+ export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundWork = false, sentMessageId = '' } = {}) {
337
419
  const list = Array.isArray(rows) ? rows : [];
338
420
  const start = list.findIndex((row) => toText(row?.id) === replyMessageId);
339
421
  const afterReply = start === -1 ? [] : list.slice(start + 1);
@@ -346,7 +428,13 @@ export function remoteFollowUpState(rows, replyMessageId, page, { sawBackgroundW
346
428
  // A queued follow-up turn shows only as activeTurn (between a task's end and
347
429
  // the follow-up turn starting); on its own, activeTurn is too weak a sign, so
348
430
  // it counts once background work or a follow-up was seen for this message.
349
- const backgroundSeen = sawBackgroundWork || tasks.length > 0 || followUps.length > 0;
431
+ // A turn in flight that belongs to no user message (not the sent one, and no
432
+ // later one exists) is a turn the agent opened itself: a follow-up that is
433
+ // running right now, whether or not a background task was ever published
434
+ // (subagents the agent waits for are not).
435
+ const turnInFlight = isPlainObject(page?.inFlight) ? toText(page.inFlight.messageId) : '';
436
+ const ownTurnInFlight = !!turnInFlight && !handedOver && !!sentMessageId && turnInFlight !== sentMessageId;
437
+ const backgroundSeen = sawBackgroundWork || tasks.length > 0 || followUps.length > 0 || ownTurnInFlight;
350
438
  const working = !handedOver && (
351
439
  tasks.length > 0
352
440
  || (isPlainObject(page?.inFlight) && backgroundSeen)
@@ -395,6 +483,25 @@ export function settleRemoteReply(messages, messageId) {
395
483
  if (looksLikeFailureText(answer.text)) return { status: 'failed', reply: replyOf(answer, { folded: true }), note };
396
484
  return { status: 'done', reply: replyOf(answer, { folded: true }), note };
397
485
  }
486
+ if (kind === ANSWERED_ELSEWHERE_KIND) {
487
+ // The marker only points at the answer, which the remote published as a
488
+ // background turn BEFORE it closed the message: the nearest continuation
489
+ // reply above the marker. The marker's own text is never the reply — it
490
+ // tells a human to resend, and a calling agent would run the prompt twice.
491
+ const answer = rows.slice(0, rows.indexOf(own)).reverse().find((row) => row?.role === 'assistant'
492
+ && toText(row.kind).toLowerCase() === 'continuation');
493
+ if (!answer) {
494
+ return {
495
+ status: 'done',
496
+ 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.',
497
+ };
498
+ }
499
+ const note = 'The remote relay published the answer to this message as a background turn; this is that turn\'s reply.';
500
+ if (looksLikeFailureText(answer.text)) {
501
+ return { status: 'failed', reply: replyOf(answer, { answeredElsewhere: true }), note };
502
+ }
503
+ return { status: 'done', reply: replyOf(answer, { answeredElsewhere: true }), note };
504
+ }
398
505
  if (looksLikeFailureText(own.text)) {
399
506
  return { status: 'failed', reply: replyOf(own), note: 'The remote turn failed; the reply is the relay\'s failure text.' };
400
507
  }
@@ -428,6 +535,10 @@ export function createRemoteRelayDispatcher({
428
535
  const inflightByConversation = new Map();
429
536
  const callTimesByConversation = new Map();
430
537
  const writeTimesByConversation = new Map();
538
+ // The loop guard: prompts sent to other relays' agents per conversation,
539
+ // counted from the user's latest own message. Kept in memory: a restart
540
+ // ends a loop of agents by itself.
541
+ const agentPromptsByConversation = new Map();
431
542
 
432
543
  // ─── Bookkeeping ───────────────────────────────────────────────────────────
433
544
 
@@ -450,6 +561,31 @@ export function createRemoteRelayDispatcher({
450
561
  }
451
562
  }
452
563
 
564
+ /**
565
+ * Why a relay is open to a conversation: 'user' (the user mentioned it),
566
+ * 'contact' (an agent on that relay wrote to the conversation, so it may be
567
+ * answered), or null (locked).
568
+ */
569
+ function relayAccess(conversationId, relay) {
570
+ if (isUnlocked(conversationId, relay?.id)) return 'user';
571
+ try {
572
+ if (toText(relay?.relayId) && repository?.hasContactFrom?.(conversationId, relay.relayId)) return 'contact';
573
+ } catch {}
574
+ return null;
575
+ }
576
+
577
+ function agentPrompts(conversationId) {
578
+ let since = null;
579
+ try {
580
+ since = repository?.latestHumanMessageId?.(conversationId) || null;
581
+ } catch {}
582
+ const kept = agentPromptsByConversation.get(conversationId);
583
+ if (kept && kept.since === since) return kept;
584
+ const fresh = { since, count: 0 };
585
+ agentPromptsByConversation.set(conversationId, fresh);
586
+ return fresh;
587
+ }
588
+
453
589
  function adjustInflight(conversationId, delta) {
454
590
  const next = inflight(conversationId) + delta;
455
591
  if (next > 0) inflightByConversation.set(conversationId, next);
@@ -638,7 +774,7 @@ export function createRemoteRelayDispatcher({
638
774
  const settled = settleRemoteReply(after.rows, messageId);
639
775
  if (settled && !settled.foldedWithoutReply) {
640
776
  if (settled.status !== 'done' || !settled.reply?.messageId) return { ...base, ...settled };
641
- const follow = remoteFollowUpState(after.rows, settled.reply.messageId, page, { sawBackgroundWork: seen.background === true });
777
+ const follow = remoteFollowUpState(after.rows, settled.reply.messageId, page, { sawBackgroundWork: seen.background === true, sentMessageId: messageId });
642
778
  if (follow.backgroundSeen) seen.background = true;
643
779
  const latestReply = follow.latest ? replyOf(follow.latest, { followUp: true }) : settled.reply;
644
780
  if (!follow.working) {
@@ -766,8 +902,18 @@ export function createRemoteRelayDispatcher({
766
902
  };
767
903
  }
768
904
 
769
- /** `[{ provider, configured, defaultModel, models }]` in contract order. */
905
+ /**
906
+ * `[{ provider, configured, defaultModel, models }]` in contract order, with
907
+ * `efforts` where the remote lists the reasoning efforts of those models.
908
+ */
770
909
  function buildProviders(catalog) {
910
+ return listProviders(catalog).map((entry) => {
911
+ const efforts = groupEffortsByModel(catalog.models, entry.provider, entry.models);
912
+ return efforts.length ? { ...entry, efforts } : entry;
913
+ });
914
+ }
915
+
916
+ function listProviders(catalog) {
771
917
  const models = catalog.models || {};
772
918
  const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
773
919
  const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
@@ -1055,7 +1201,7 @@ export function createRemoteRelayDispatcher({
1055
1201
  * When the replay fails too, nobody knows; the error carries the session
1056
1202
  * and message id so the agent checks with wait instead of sending again.
1057
1203
  */
1058
- async function postPrompt(ctx, { session, text, origin, model, mode }) {
1204
+ async function postPrompt(ctx, { session, text, origin, model, mode, effort }) {
1059
1205
  const messageId = String(randomUUID());
1060
1206
  const body = {
1061
1207
  conversationId: session,
@@ -1064,6 +1210,7 @@ export function createRemoteRelayDispatcher({
1064
1210
  origin,
1065
1211
  ...(model ? { model } : {}),
1066
1212
  ...(mode ? { relayMode: mode } : {}),
1213
+ ...(effort ? { reasoningEffort: effort } : {}),
1067
1214
  };
1068
1215
  let sent;
1069
1216
  try {
@@ -1090,11 +1237,72 @@ export function createRemoteRelayDispatcher({
1090
1237
  messageId: toText(sent.duplicateOfMessageId) || null,
1091
1238
  };
1092
1239
  }
1093
- return { duplicate: false, messageId: toText(sent?.messageId) || messageId };
1240
+ return {
1241
+ duplicate: false,
1242
+ messageId: toText(sent?.messageId) || messageId,
1243
+ effort: normalizeEffort(sent?.selectedReasoningEffort),
1244
+ };
1094
1245
  }
1095
1246
 
1096
- async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra = {} }) {
1247
+ /**
1248
+ * What the remote made of the effort: `reported` is the one it bound (the
1249
+ * bootstrap's preferredReasoningEffort, the message's
1250
+ * selectedReasoningEffort). Most providers do not refuse an effort their
1251
+ * model lacks, they fall back to their default, so the result names the
1252
+ * effort the turn runs with and says when that is not the one asked for.
1253
+ */
1254
+ function settleEffort(ctx, choice, reported) {
1255
+ const bound = normalizeEffort(reported);
1256
+ if (!bound || bound === choice.effort) return choice;
1257
+ if (!choice.effort) return { ...choice, effort: bound, source: EFFORT_SOURCES.remote };
1258
+ return {
1259
+ effort: bound,
1260
+ source: EFFORT_SOURCES.remote,
1261
+ note: choice.note
1262
+ || `Relay "${ctx.relay.name}" does not offer effort "${choice.effort}" for this model; the turn runs with "${bound}".`,
1263
+ };
1264
+ }
1265
+
1266
+ /**
1267
+ * Queues the prompt with the chosen effort. An effort the agent did not ask
1268
+ * for (this session's, the remote session's own) that the remote refuses is
1269
+ * dropped and the prompt sent once more without it; one the agent asked for
1270
+ * is the remote's to refuse, so that error goes back as it is.
1271
+ */
1272
+ async function postPromptWithEffort(ctx, prompt, choice) {
1273
+ try {
1274
+ const sent = await postPrompt(ctx, { ...prompt, effort: choice.effort });
1275
+ return { sent, choice: settleEffort(ctx, choice, sent.effort) };
1276
+ } catch (error) {
1277
+ if (!choice.effort || choice.source === EFFORT_SOURCES.requested || !isEffortRejection(error)) throw error;
1278
+ const dropped = {
1279
+ effort: '',
1280
+ source: EFFORT_SOURCES.remote,
1281
+ 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.`,
1282
+ };
1283
+ const sent = await postPrompt(ctx, { ...prompt, effort: '' });
1284
+ return { sent, choice: settleEffort(ctx, dropped, sent.effort) };
1285
+ }
1286
+ }
1287
+
1288
+ /** The `effort` / `effortSource` fields of a result, and the note that goes with them. */
1289
+ function effortResult(choice) {
1290
+ return {
1291
+ ...(choice.source ? { effort: choice.effort || null, effortSource: choice.source } : {}),
1292
+ ...(choice.note ? { note: choice.note } : {}),
1293
+ };
1294
+ }
1295
+
1296
+ async function deliverAndWait(action, ctx, { session, sent, waitSeconds, extra: given = {} }) {
1097
1297
  const openUrl = remoteConversationUrl(ctx.relay.url, session);
1298
+ // `extra.note` (how the effort was settled) goes in front of the outcome's own.
1299
+ const { note: extraNote, ...extra } = given;
1300
+ const result = await deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl });
1301
+ const note = [extraNote, result.note].map(toText).filter(Boolean).join(' ');
1302
+ return note ? { ...result, note } : result;
1303
+ }
1304
+
1305
+ async function deliverOutcome(action, ctx, { session, sent, waitSeconds, extra, openUrl }) {
1098
1306
  if (sent.duplicate) {
1099
1307
  const outcome = {
1100
1308
  status: 'duplicate',
@@ -1128,9 +1336,18 @@ export function createRemoteRelayDispatcher({
1128
1336
  const model = args.model
1129
1337
  || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
1130
1338
  const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
1339
+ // The remote runs a prompt that names no effort with its provider's
1340
+ // default, not with the one the session is set to: pass that one on, so
1341
+ // the session keeps its own. This session's effort is never mirrored here.
1342
+ const requested = normalizeEffort(args.effort);
1343
+ const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
1131
1344
  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 });
1345
+ const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
1346
+ effort: requested || own,
1347
+ source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
1348
+ note: null,
1349
+ });
1350
+ return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds, extra: effortResult(choice) });
1134
1351
  }
1135
1352
 
1136
1353
  async function createSession(ctx, args) {
@@ -1163,23 +1380,75 @@ export function createRemoteRelayDispatcher({
1163
1380
  const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
1164
1381
  const origin = await buildOrigin(ctx);
1165
1382
 
1166
- const bootstrap = await call(ctx, 'POST', '/api/conversation/bootstrap', {
1383
+ // The effort mirrors the caller like the model does: this session's when
1384
+ // the remote lists it for the model (or lists nothing), else the remote's.
1385
+ let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
1386
+ if (!choice.effort) {
1387
+ const callerEffort = normalizeEffort(ctx.caller.effort);
1388
+ const offered = catalogEffortsFor(catalog.models, provider, model);
1389
+ if (callerEffort && (!offered || offered.includes(callerEffort))) {
1390
+ choice = { effort: callerEffort, source: EFFORT_SOURCES.caller, note: null };
1391
+ } else {
1392
+ choice = {
1393
+ effort: '',
1394
+ source: EFFORT_SOURCES.remote,
1395
+ note: callerEffort
1396
+ ? `${model || provider} on relay "${ctx.relay.name}" does not offer this session's effort "${callerEffort}"${supportedEffortsText(offered || [])}; the session uses the remote's default.`
1397
+ : null,
1398
+ };
1399
+ }
1400
+ }
1401
+
1402
+ const bootstrapWith = (effort) => call(ctx, 'POST', '/api/conversation/bootstrap', {
1167
1403
  body: {
1168
1404
  providerType: provider,
1169
1405
  ...(model ? { model } : {}),
1170
1406
  ...(mode ? { relayMode: mode } : {}),
1407
+ ...(effort ? { reasoningEffort: effort } : {}),
1171
1408
  title,
1172
1409
  ...(args.cwd ? { cwd: args.cwd } : {}),
1173
1410
  origin,
1174
1411
  },
1175
1412
  });
1413
+ let bootstrap;
1414
+ try {
1415
+ bootstrap = await bootstrapWith(choice.effort);
1416
+ } catch (error) {
1417
+ // Only an effort the agent did not ask for is dropped; a refused
1418
+ // bootstrap created nothing, so asking again is safe.
1419
+ if (!choice.effort || choice.source === EFFORT_SOURCES.requested || !isEffortRejection(error)) throw error;
1420
+ choice = {
1421
+ effort: '',
1422
+ source: EFFORT_SOURCES.remote,
1423
+ 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.`,
1424
+ };
1425
+ bootstrap = await bootstrapWith('');
1426
+ }
1427
+ choice = settleEffort(ctx, choice, bootstrap?.preferredReasoningEffort);
1176
1428
  const session = toText(bootstrap?.conversationId);
1177
1429
  if (!session) {
1178
1430
  throw new DispatchFailure(502, `${REMOTE_RELAY_ERROR_CODES.httpPrefix}502`,
1179
1431
  `Relay "${ctx.relay.name}" did not return the new session's id.`);
1180
1432
  }
1181
1433
  const boundModel = toText(bootstrap?.selectedModel) || model;
1182
- const sent = await postPrompt(ctx, { session, text: args.text, origin, model: boundModel, mode });
1434
+ let sent;
1435
+ try {
1436
+ ({ sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice));
1437
+ } catch (error) {
1438
+ if (!choice.effort || !isEffortRejection(error)) throw error;
1439
+ // The session exists by now: say so, or the agent starts a second one.
1440
+ const supported = supportedEffortsOf(error);
1441
+ const remoteCode = toText(error.remoteBody?.code);
1442
+ throw new DispatchFailure(400, toText(error.code) || `${REMOTE_RELAY_ERROR_CODES.httpPrefix}400`,
1443
+ `${toText(error.message) || `Relay "${ctx.relay.name}" refused effort "${choice.effort}"`}. The session was created, but the prompt was not queued.`,
1444
+ {
1445
+ session,
1446
+ ...(remoteCode ? { remoteCode } : {}),
1447
+ ...(supported.length ? { supportedEfforts: supported } : {}),
1448
+ openUrl: remoteConversationUrl(ctx.relay.url, session),
1449
+ note: 'Use send with this session and an effort it takes (or none); do not create another session.',
1450
+ });
1451
+ }
1183
1452
  return deliverAndWait('create_session', ctx, {
1184
1453
  session,
1185
1454
  sent,
@@ -1192,6 +1461,7 @@ export function createRemoteRelayDispatcher({
1192
1461
  title,
1193
1462
  ...(toText(bootstrap?.workspaceRootWarning) ? { cwdWarning: toText(bootstrap.workspaceRootWarning) } : {}),
1194
1463
  ...(note ? { modelNote: note } : {}),
1464
+ ...effortResult(choice),
1195
1465
  },
1196
1466
  });
1197
1467
  }
@@ -1332,10 +1602,13 @@ export function createRemoteRelayDispatcher({
1332
1602
  }
1333
1603
  const remoteCode = toText(remoteBody?.code) || null;
1334
1604
  const supportedModels = Array.isArray(remoteBody?.supportedModels) ? remoteBody.supportedModels.slice(0, 50) : null;
1605
+ // A refused effort: the ones the remote's model takes, so the agent can pick one.
1606
+ const supportedEfforts = isEffortRejection(error) ? supportedEffortsOf(error) : [];
1335
1607
  const details = {
1336
1608
  ...extra,
1337
1609
  ...(remoteCode ? { remoteCode } : {}),
1338
1610
  ...(supportedModels ? { supportedModels } : {}),
1611
+ ...(supportedEfforts.length ? { supportedEfforts } : {}),
1339
1612
  };
1340
1613
  if (code.startsWith(C.httpPrefix) && status >= 400 && status < 500) return failure(status, code, message, details);
1341
1614
  return failure(502, code || `${C.httpPrefix}502`, message, details);
@@ -1351,7 +1624,7 @@ export function createRemoteRelayDispatcher({
1351
1624
  online: toText(relay.lastStatus).toLowerCase() === 'online',
1352
1625
  version: relay.version || null,
1353
1626
  permission: relay.permission,
1354
- unlocked: isUnlocked(conversationId, relay.id),
1627
+ unlocked: relayAccess(conversationId, relay) !== null,
1355
1628
  }));
1356
1629
  const unlocked = listed.filter((relay) => relay.unlocked).length;
1357
1630
  return {
@@ -1361,7 +1634,7 @@ export function createRemoteRelayDispatcher({
1361
1634
  summary: `list_relays: ${listed.length} relay${listed.length === 1 ? '' : 's'}, ${unlocked} unlocked`,
1362
1635
  self: { name: (await registry.selfName?.()) || null },
1363
1636
  relays: listed,
1364
- hint: 'Mention @name in the chat to unlock a relay.',
1637
+ hint: 'The user unlocks a relay by mentioning @name in the chat; a relay whose agent wrote to this conversation is open as well.',
1365
1638
  },
1366
1639
  };
1367
1640
  }
@@ -1392,7 +1665,8 @@ export function createRemoteRelayDispatcher({
1392
1665
  return result;
1393
1666
  };
1394
1667
 
1395
- if (!isUnlocked(conversationId, relay.id)) {
1668
+ const access = relayAccess(conversationId, relay);
1669
+ if (!access) {
1396
1670
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.locked,
1397
1671
  `Relay "${relay.name}" is locked in this conversation. Ask the user to mention @${relay.name} (or its name) in a message to allow work there.`,
1398
1672
  { relay: relay.name }));
@@ -1406,9 +1680,16 @@ export function createRemoteRelayDispatcher({
1406
1680
  const caller = { conversationId, ...((await getCallerContext(conversationId, req)) || {}) };
1407
1681
  caller.mode = toText(caller.mode).toLowerCase();
1408
1682
  const hops = nonNegativeInt(caller.hops) + 1;
1409
- if (hops > REMOTE_RELAY_LIMITS.hopLimit) {
1683
+ // The hop limit stops a prompt from travelling on to a third relay. A
1684
+ // relay the user unlocked here stays in reach whatever prompts arrive,
1685
+ // the relay the prompts came from may always be answered, and reading
1686
+ // carries nothing on.
1687
+ const origins = Array.isArray(caller.originRelayIds) ? caller.originRelayIds.map(toText) : [];
1688
+ const answering = !!toText(relay.relayId) && origins.length > 0
1689
+ && origins.every((id) => id === toText(relay.relayId));
1690
+ if (write && access !== 'user' && !answering && hops > REMOTE_RELAY_LIMITS.hopLimit) {
1410
1691
  return finish(failure(403, REMOTE_RELAY_ERROR_CODES.hopLimit,
1411
- `This turn was started by another relay's agent (hop ${hops - 1}); forwarding it again would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops.`,
1692
+ `This turn acts on a prompt from another relay's agent (hop ${hops - 1}); passing it on to relay "${relay.name}" would pass the limit of ${REMOTE_RELAY_LIMITS.hopLimit} hops. The user can allow it by mentioning @${relay.name} in this conversation.`,
1412
1693
  { relay: relay.name }));
1413
1694
  }
1414
1695
 
@@ -1421,7 +1702,32 @@ export function createRemoteRelayDispatcher({
1421
1702
 
1422
1703
  adjustInflight(conversationId, 1);
1423
1704
  try {
1424
- if (write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
1705
+ const prompting = isRemoteRelayPromptAction(action);
1706
+ let asked = false;
1707
+ if (prompting && agentPrompts(conversationId).count >= REMOTE_RELAY_LIMITS.agentPromptsBeforeAsking) {
1708
+ // Agents that prompt each other can go on without the user for good:
1709
+ // from time to time the user is asked, in any relay mode.
1710
+ const decision = await requestApproval({
1711
+ conversationId,
1712
+ callerContext: caller,
1713
+ relay: relayTarget(relay),
1714
+ action,
1715
+ args,
1716
+ summary: summarizeRemoteRelayCall({ action, ...args, relay: relay.name }),
1717
+ signal,
1718
+ agentPrompts: agentPrompts(conversationId).count,
1719
+ });
1720
+ const approved = decision === true || decision?.approved === true;
1721
+ if (!approved) {
1722
+ const code = toText(decision?.code) || REMOTE_RELAY_ERROR_CODES.approvalDenied;
1723
+ const error = toText(decision?.error)
1724
+ || `The agent has sent ${agentPrompts(conversationId).count} prompts to other relays' agents since the user last wrote, and the user did not allow more for now.`;
1725
+ return finish(failure(403, code, error, { relay: relay.name }));
1726
+ }
1727
+ agentPrompts(conversationId).count = 0;
1728
+ asked = true;
1729
+ }
1730
+ if (!asked && write && REMOTE_RELAY_APPROVAL_MODES.includes(caller.mode)) {
1425
1731
  const decision = await requestApproval({
1426
1732
  conversationId,
1427
1733
  callerContext: caller,
@@ -1442,6 +1748,7 @@ export function createRemoteRelayDispatcher({
1442
1748
 
1443
1749
  const ctx = { relay, caller, hops, conversationId, signal };
1444
1750
  const body = await ACTIONS[action](ctx, args);
1751
+ if (prompting) agentPrompts(conversationId).count += 1;
1445
1752
  const { summary, ...rest } = body;
1446
1753
  return finish({
1447
1754
  status: 200,
@@ -161,6 +161,41 @@ export function formatRemoteRelayMentionHint(relays = []) {
161
161
  return `<system_reminder>The user mentioned ${subject}. The ${REMOTE_RELAY_TOOL_NAME} tool can list, read, prompt and create sessions there.</system_reminder>`;
162
162
  }
163
163
 
164
+ /**
165
+ * The paired relay a remote prompt came from: `{ relayId, name }` (`relayId`
166
+ * is the local registry id), or null when the sender is not paired here or
167
+ * names no relay id.
168
+ */
169
+ export function findOriginRemoteRelay(inbound, origin) {
170
+ const instance = String(origin?.relayId || '').trim();
171
+ if (!inbound || typeof inbound.listRelays !== 'function' || !instance) return null;
172
+ try {
173
+ const listed = inbound.listRelays();
174
+ const relay = (Array.isArray(listed) ? listed : [])
175
+ .find((entry) => String(entry?.relayId || '').trim() === instance);
176
+ return relay ? { relayId: String(relay.id || ''), name: String(relay.name || relay.id || '') } : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * The reminder the agent gets with a prompt another relay's agent sent: that
184
+ * relay is open to this conversation, and where the sender's session is. ''
185
+ * when the sender is not a paired relay. Like the mention hint, it rides in
186
+ * the queued prompt only.
187
+ */
188
+ export function formatRemoteRelayOriginHint(relay, origin) {
189
+ const name = hintText(relay?.name);
190
+ if (!name) return '';
191
+ const session = hintText(origin?.conversationId, 80);
192
+ const where = session ? `relay "${name}", session "${session}"` : `relay "${name}"`;
193
+ return `<system_reminder>This prompt came from an agent on the paired OAR relay "${name}". `
194
+ + 'Your reply to it is handed back to that agent by itself. '
195
+ + `For more than the reply (reading its session, a question, a later report) the ${REMOTE_RELAY_TOOL_NAME} tool is open for that relay in this conversation: ${where}. `
196
+ + 'A prompt you send there while its turn is still running is steered into that turn.</system_reminder>';
197
+ }
198
+
164
199
  /** The queued prompt with the mention hint after the user's text. */
165
200
  export function withRemoteRelayMentionHint(promptText, hint) {
166
201
  const text = String(promptText ?? '');
@@ -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) {