@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.
- package/CHANGELOG.md +31 -0
- package/README.md +17 -4
- package/package.json +1 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +293 -18
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/index.html +7 -3
- package/server/routes/messages-routes.mjs +8 -3
- package/server/routes/sessions-routes.mjs +4 -2
- package/server/services/remote-relay-caller-context.mjs +8 -3
- package/server/services/remote-relay-dispatcher.mjs +256 -13
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/shared/remote-relay-contract.mjs +17 -0
- package/shared/steer-settle-failure.mjs +6 -0
- package/shared/steer-settle-markers.mjs +16 -0
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -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
|
-
/**
|
|
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) {
|
package/server/public/index.html
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
1776
|
-
//
|
|
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(`
|
|
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
|
-
//
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
|
1133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) => ({
|