@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +199 -0
- package/README.md +35 -6
- package/package.json +2 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +411 -19
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +97 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +97 -4
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +93 -17
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +40 -2
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +45 -20
- package/server/services/remote-relay-dispatcher.mjs +328 -21
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +33 -2
- package/shared/steer-settle-failure.mjs +22 -0
- package/shared/steer-settle-markers.mjs +24 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
- 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
|
-
//
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
1240
|
+
return {
|
|
1241
|
+
duplicate: false,
|
|
1242
|
+
messageId: toText(sent?.messageId) || messageId,
|
|
1243
|
+
effort: normalizeEffort(sent?.selectedReasoningEffort),
|
|
1244
|
+
};
|
|
1094
1245
|
}
|
|
1095
1246
|
|
|
1096
|
-
|
|
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
|
|
1133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) {
|