@oar-sh/oar 0.9.13 → 0.9.14
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/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +302 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +296 -149
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +4 -2
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -17,6 +17,7 @@ import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-fil
|
|
|
17
17
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
18
18
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
19
19
|
import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
|
|
20
|
+
import { resolveCursorSessionStoreDir } from '../services/cursor-session-root-service.mjs';
|
|
20
21
|
|
|
21
22
|
// Windows console launch: the launcher names the worker log, this copies
|
|
22
23
|
// stdout/stderr into it. A no-op wherever the launcher redirects instead.
|
|
@@ -57,8 +58,14 @@ async function main() {
|
|
|
57
58
|
// renewal hint, which is more visible to the user than a dead worker.
|
|
58
59
|
console.warn('cursor-session-worker: CURSOR_API_KEY is not set; turns will fail until it is provided');
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// `$CURSOR_AGENT_STORE_DIR`, else `cursor-agents` in the relay's data dir:
|
|
62
|
+
// the same folder the relay's Session root looks in.
|
|
63
|
+
const storeDir = resolveCursorSessionStoreDir({
|
|
64
|
+
env: process.env,
|
|
65
|
+
serverDir: path.resolve(__dirname, '..'),
|
|
66
|
+
configPath,
|
|
67
|
+
sdkSessionId,
|
|
68
|
+
});
|
|
62
69
|
|
|
63
70
|
const api = createApiClient({
|
|
64
71
|
serverUrl,
|
|
@@ -23,6 +23,7 @@ import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instr
|
|
|
23
23
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
24
24
|
import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
|
|
25
25
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
26
|
+
import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
26
27
|
|
|
27
28
|
// Stale-auth recovery budget per turn: expiry is routine on long-lived
|
|
28
29
|
// handles, and one retry proved too small in practice (an orchestrator turn
|
|
@@ -122,6 +123,9 @@ export function createCursorTurnRunner({
|
|
|
122
123
|
resolveModelParamsImpl = resolveCursorReasoningParams,
|
|
123
124
|
createNormalizerImpl = createSdkMessageNormalizer,
|
|
124
125
|
buildUserMessageImpl = buildCursorUserMessage,
|
|
126
|
+
// The error an agent started in `cwd` fails with up front, or null
|
|
127
|
+
// (shared/worker-runtime/working-directory.mjs).
|
|
128
|
+
checkWorkingDirectoryImpl = missingWorkingDirectoryError,
|
|
125
129
|
// Whether agent handles carry the relay's `remote_relay` tool
|
|
126
130
|
// (shared/remote-relay-tool-core.mjs). Default: ask the relay.
|
|
127
131
|
remoteRelayToolGate = null,
|
|
@@ -174,7 +178,9 @@ export function createCursorTurnRunner({
|
|
|
174
178
|
// plain refusal the model can relay instead of a failed turn.
|
|
175
179
|
const previewTool = createPreviewTool({
|
|
176
180
|
api,
|
|
177
|
-
|
|
181
|
+
// Between turns, the worker's own session (as remote_relay below): list
|
|
182
|
+
// and close are scoped to a conversation and refused without one.
|
|
183
|
+
getConversationId: () => activeMessage?.conversationId || sdkSessionId || '',
|
|
178
184
|
dbg,
|
|
179
185
|
});
|
|
180
186
|
const customTools = {
|
|
@@ -301,6 +307,10 @@ export function createCursorTurnRunner({
|
|
|
301
307
|
agentHandle = null;
|
|
302
308
|
}
|
|
303
309
|
if (!agentHandle) {
|
|
310
|
+
// The agent works in the conversation's folder: a folder that is gone
|
|
311
|
+
// fails the turn with its name (and is no reason to drop the agent).
|
|
312
|
+
const missingFolder = checkWorkingDirectoryImpl(cwd);
|
|
313
|
+
if (missingFolder) throw missingFolder;
|
|
304
314
|
const durableId = String(message.cursorAgentId || '').trim() || cursorAgentId;
|
|
305
315
|
const agents = buildCursorSubagentAgents(rosterModels);
|
|
306
316
|
await remoteRelayGate.ready();
|
|
@@ -309,11 +319,15 @@ export function createCursorTurnRunner({
|
|
|
309
319
|
try {
|
|
310
320
|
agentHandle = await createAgentHandleImpl({ ...options, agentId: durableId });
|
|
311
321
|
} catch (error) {
|
|
312
|
-
if (!durableId) throw error;
|
|
322
|
+
if (!durableId) throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl });
|
|
313
323
|
// The durable agent may have been pruned server-side; fall back to a
|
|
314
324
|
// fresh agent rather than wedging the conversation.
|
|
315
325
|
dbg('cursor agent resume failed, creating fresh agent', error?.message || String(error));
|
|
316
|
-
|
|
326
|
+
try {
|
|
327
|
+
agentHandle = await createAgentHandleImpl({ ...options, agentId: '' });
|
|
328
|
+
} catch (freshError) {
|
|
329
|
+
throw explainLaunchFailure(freshError, cwd, { check: checkWorkingDirectoryImpl });
|
|
330
|
+
}
|
|
317
331
|
}
|
|
318
332
|
}
|
|
319
333
|
// Only recorded once a handle exists: a create that threw leaves the
|
|
@@ -787,6 +801,7 @@ export function createCursorTurnRunner({
|
|
|
787
801
|
// A stall is told in the user's terms, and with its own advice: the
|
|
788
802
|
// run was cancelled, so sending the message again is all it takes.
|
|
789
803
|
message: classified.text || classified.message,
|
|
804
|
+
...(classified.guidance ? { guidance: classified.guidance } : {}),
|
|
790
805
|
...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
|
|
791
806
|
failedAt: new Date().toISOString(),
|
|
792
807
|
queueMessageId: String(message.id || '') || null,
|
|
@@ -6,12 +6,26 @@ import { AcpClient, extractGrokModelsFromInitialize, pickAllowOnceOptionId } fro
|
|
|
6
6
|
import { createAcpHostServices } from './acp-host-services.mjs';
|
|
7
7
|
import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
|
|
8
8
|
import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
9
|
+
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
9
10
|
|
|
10
11
|
export { extractGrokModelsFromInitialize };
|
|
11
12
|
|
|
12
13
|
export function classifyGrokError(error) {
|
|
13
14
|
const message = String(error?.message || error || '').trim();
|
|
14
15
|
const lower = message.toLowerCase();
|
|
16
|
+
// First, and above all before the ENOENT branch: a spawn in a folder that
|
|
17
|
+
// is gone fails with ENOENT too, and the runner puts it down to the folder,
|
|
18
|
+
// whose path may hold any of the words matched below.
|
|
19
|
+
if (isWorkingDirectoryMissingError(error)) {
|
|
20
|
+
return {
|
|
21
|
+
code: `grok.${WORKING_DIRECTORY_MISSING_CODE}`,
|
|
22
|
+
message,
|
|
23
|
+
text: message,
|
|
24
|
+
guidance: error.guidance,
|
|
25
|
+
isAuth: false,
|
|
26
|
+
isBusy: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
15
29
|
// No bare 'auth' token: it would match unrelated words ("author…") in
|
|
16
30
|
// arbitrary agent error text and misclassify them as login failures.
|
|
17
31
|
if (
|
|
@@ -12,6 +12,7 @@ import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mj
|
|
|
12
12
|
import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
|
|
13
13
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
14
14
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
15
|
+
import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
15
16
|
|
|
16
17
|
const PLAN_BOARD_ACTIONS = [
|
|
17
18
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -64,6 +65,7 @@ function buildGrokTerminalError(classified, message) {
|
|
|
64
65
|
code: classified.code,
|
|
65
66
|
stableCode: classified.code,
|
|
66
67
|
message: classified.text || classified.message,
|
|
68
|
+
...(classified.guidance ? { guidance: classified.guidance } : {}),
|
|
67
69
|
// A stalled turn was cancelled and its agent is restarted for the next
|
|
68
70
|
// message, so sending the message again is all it takes.
|
|
69
71
|
...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
|
|
@@ -115,6 +117,9 @@ export function createGrokTurnRunner({
|
|
|
115
117
|
createAgentHandleImpl = createGrokAgentHandle,
|
|
116
118
|
startGrokTurnImpl = startGrokTurn,
|
|
117
119
|
classifyErrorImpl = classifyGrokError,
|
|
120
|
+
// The error a Grok CLI started in `cwd` fails with up front, or null
|
|
121
|
+
// (shared/worker-runtime/working-directory.mjs).
|
|
122
|
+
checkWorkingDirectoryImpl = missingWorkingDirectoryError,
|
|
118
123
|
getPreviewInstructions = createPreviewInstructionsProvider({ api }),
|
|
119
124
|
env = process.env,
|
|
120
125
|
nodePath = process.execPath,
|
|
@@ -200,6 +205,11 @@ export function createGrokTurnRunner({
|
|
|
200
205
|
|
|
201
206
|
async function ensureAgentHandle(message, model) {
|
|
202
207
|
if (agentHandle) return agentHandle;
|
|
208
|
+
// The CLI is spawned in the conversation's folder; a folder that is gone
|
|
209
|
+
// fails with ENOENT, which would otherwise read as a CLI that is not
|
|
210
|
+
// installed (classifyGrokError).
|
|
211
|
+
const missingFolder = checkWorkingDirectoryImpl(cwd);
|
|
212
|
+
if (missingFolder) throw missingFolder;
|
|
203
213
|
const resumeId = String(message.grokNativeSessionId || grokNativeSessionId || '').trim();
|
|
204
214
|
agentHandle = await createAgentHandleImpl({
|
|
205
215
|
command,
|
|
@@ -215,7 +225,7 @@ export function createGrokTurnRunner({
|
|
|
215
225
|
? { AcpClientImpl: withAcpMcpServers(AcpClient, acpMcpServers), mcpServers: acpMcpServers }
|
|
216
226
|
: {}),
|
|
217
227
|
dbg,
|
|
218
|
-
});
|
|
228
|
+
}).catch((error) => { throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl }); });
|
|
219
229
|
if (agentHandle.sessionId) {
|
|
220
230
|
await persistNativeSessionId(message, agentHandle.sessionId);
|
|
221
231
|
}
|
|
@@ -427,7 +437,7 @@ export function createGrokTurnRunner({
|
|
|
427
437
|
const previewBlock = previewInstructionsSent || acpMcpServers.length
|
|
428
438
|
? ''
|
|
429
439
|
: await Promise.resolve()
|
|
430
|
-
.then(() => getPreviewInstructions?.())
|
|
440
|
+
.then(() => getPreviewInstructions?.({ conversationId: message.conversationId || sdkSessionId || '' }))
|
|
431
441
|
.catch(() => '');
|
|
432
442
|
const mediaBlock = mediaInstructionsSent ? '' : renderMediaEmbedInstructionBlock();
|
|
433
443
|
const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
|
|
@@ -594,7 +604,7 @@ export function createGrokTurnRunner({
|
|
|
594
604
|
: classified.isStalled
|
|
595
605
|
? classified.text
|
|
596
606
|
|| `System note: the Grok turn stalled and was stopped (${classified.message}). Send a new message to retry.`
|
|
597
|
-
: `System note: the Grok turn failed (${classified.message}).`;
|
|
607
|
+
: classified.text || `System note: the Grok turn failed (${classified.message}).`;
|
|
598
608
|
await publishResponse(message, {
|
|
599
609
|
text: noteText,
|
|
600
610
|
model: message.providerModel || defaultModel || null,
|
|
@@ -1560,13 +1560,15 @@ export async function loadRelayQuestion(questionId) {
|
|
|
1560
1560
|
return apiFetch(`/api/relay-question/${encodeURIComponent(id)}`);
|
|
1561
1561
|
}
|
|
1562
1562
|
|
|
1563
|
-
|
|
1563
|
+
// `onErrorResponse` reads the relay's refusal (why, and a card's choices).
|
|
1564
|
+
export async function answerRelayQuestion(questionId, answer, sdkSessionId = null, { onErrorResponse } = {}) {
|
|
1564
1565
|
const id = String(questionId || '').trim();
|
|
1565
1566
|
if (!id) return null;
|
|
1566
1567
|
const sessionId = String(sdkSessionId || '').trim();
|
|
1567
1568
|
return apiFetch(`/api/relay-question/${encodeURIComponent(id)}/answer`, {
|
|
1568
1569
|
method: 'POST',
|
|
1569
1570
|
body: JSON.stringify({ answer, sdk_session_id: sessionId || undefined }),
|
|
1571
|
+
onErrorResponse,
|
|
1570
1572
|
});
|
|
1571
1573
|
}
|
|
1572
1574
|
|
|
@@ -14,6 +14,7 @@ import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
|
|
|
14
14
|
import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
|
|
15
15
|
import { isChatInteractionHeld } from './selection-guard.mjs';
|
|
16
16
|
import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
|
|
17
|
+
import { questionAcceptsFreeText } from './question-answer-rule.mjs';
|
|
17
18
|
import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
18
19
|
|
|
19
20
|
let relayQuestionRenderHash = '';
|
|
@@ -265,6 +266,10 @@ export function renderRelayQuestions() {
|
|
|
265
266
|
const modeTag = question.mode ? ` <span class="msg-mode">${escHtml(question.mode)}</span>` : '';
|
|
266
267
|
const contextText = String(question?.context?.rationale || '').trim();
|
|
267
268
|
const contextHtml = contextText ? `<div class="relay-question-context">${renderLinkedPlainText(contextText)}</div>` : '';
|
|
269
|
+
// The card's own title ("Agent sessions", "Remote relay", a Claude
|
|
270
|
+
// question's header), above the question.
|
|
271
|
+
const headerText = String(question?.context?.header || '').trim();
|
|
272
|
+
const headerHtml = headerText ? `<div class="relay-question-header">${escHtml(headerText)}</div>` : '';
|
|
268
273
|
const fields = schemaFieldsFromQuestion(question);
|
|
269
274
|
const structuredQuestion = fields.length > 0;
|
|
270
275
|
|
|
@@ -296,14 +301,21 @@ export function renderRelayQuestions() {
|
|
|
296
301
|
choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
|
|
297
302
|
}</div>`;
|
|
298
303
|
}
|
|
299
|
-
|
|
304
|
+
// A card that takes no typed text (approval and permission cards) is
|
|
305
|
+
// answered by its choices alone: no reply box, and a multi-select one
|
|
306
|
+
// keeps only its "Reply with selection".
|
|
307
|
+
const freeText = questionAcceptsFreeText(question);
|
|
308
|
+
const showReplyControls = question.status === 'pending' && (freeText || multiSelect);
|
|
300
309
|
const draftText = String(relayQuestionDrafts.get(question.id) || '');
|
|
301
310
|
const replyPlaceholder = multiSelect
|
|
302
311
|
? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
|
|
303
312
|
: 'Type a reply… (Ctrl/Cmd+Enter to send)';
|
|
313
|
+
const replyInputHtml = freeText
|
|
314
|
+
? `<textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>`
|
|
315
|
+
: '';
|
|
304
316
|
const replyHtml = showReplyControls
|
|
305
317
|
? `<div class="relay-question-reply">
|
|
306
|
-
|
|
318
|
+
${replyInputHtml}
|
|
307
319
|
<button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
|
|
308
320
|
</div>`
|
|
309
321
|
: '';
|
|
@@ -320,6 +332,7 @@ export function renderRelayQuestions() {
|
|
|
320
332
|
wrapper.innerHTML = `
|
|
321
333
|
<div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
|
|
322
334
|
<div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
|
|
335
|
+
${headerHtml}
|
|
323
336
|
<div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
|
|
324
337
|
${contextHtml}
|
|
325
338
|
${answeredHtml}
|
|
@@ -580,6 +593,15 @@ async function enqueueRelayAnswerForSync(questionId, body) {
|
|
|
580
593
|
return queued;
|
|
581
594
|
}
|
|
582
595
|
|
|
596
|
+
// The relay's reason for refusing an answer (a card that takes only its
|
|
597
|
+
// choices names them), or '' when it gave none.
|
|
598
|
+
function relayAnswerRefusalText(refusal) {
|
|
599
|
+
const reason = String(refusal?.error || '').trim();
|
|
600
|
+
if (!reason) return '';
|
|
601
|
+
const choices = Array.isArray(refusal?.choices) ? refusal.choices.map((choice) => String(choice)).filter(Boolean) : [];
|
|
602
|
+
return choices.length ? `${reason}. Choices: ${choices.join(', ')}.` : reason;
|
|
603
|
+
}
|
|
604
|
+
|
|
583
605
|
export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
|
|
584
606
|
const input = document.getElementById(`relay-question-input-${questionId}`);
|
|
585
607
|
// Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
|
|
@@ -600,15 +622,18 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
|
|
|
600
622
|
const question = relayQuestions.get(questionId) || null;
|
|
601
623
|
const sdkSessionId = String(question?.sdkSessionId || '').trim();
|
|
602
624
|
const singleFieldStructuredAnswer = resolveSingleFieldStructuredAnswer(question, answer);
|
|
625
|
+
let refusal = null;
|
|
603
626
|
const r = singleFieldStructuredAnswer
|
|
604
627
|
? await answerRelayQuestionStructured(questionId, singleFieldStructuredAnswer, sdkSessionId || null)
|
|
605
|
-
: await answerRelayQuestion(questionId, answer, sdkSessionId || null
|
|
628
|
+
: await answerRelayQuestion(questionId, answer, sdkSessionId || null, {
|
|
629
|
+
onErrorResponse: async (response) => { refusal = await response.json().catch(() => null); },
|
|
630
|
+
});
|
|
606
631
|
if (!r?.question) {
|
|
607
632
|
const queued = await enqueueRelayAnswerForSync(questionId, singleFieldStructuredAnswer
|
|
608
633
|
? { structuredAnswer: singleFieldStructuredAnswer, sdk_session_id: sdkSessionId || undefined }
|
|
609
634
|
: { answer, sdk_session_id: sdkSessionId || undefined });
|
|
610
635
|
if (queued) return;
|
|
611
|
-
throw new Error('Failed to submit relay question answer');
|
|
636
|
+
throw new Error(relayAnswerRefusalText(refusal) || 'Failed to submit relay question answer');
|
|
612
637
|
}
|
|
613
638
|
relayQuestionDrafts.delete(questionId);
|
|
614
639
|
relayQuestionStructuredDrafts.delete(questionId);
|
|
@@ -33,15 +33,25 @@ export function isConversationUsingClaudeCloudProvider(conversation = null) {
|
|
|
33
33
|
// The marker that tells a cloud chat from a local Claude one at a glance.
|
|
34
34
|
const CLAUDE_CLOUD_MARKER = '☁';
|
|
35
35
|
|
|
36
|
+
const PROVIDER_NAMES = Object.freeze({
|
|
37
|
+
openai: 'OpenAI',
|
|
38
|
+
claude: 'Claude',
|
|
39
|
+
'claude-cloud': 'Claude Cloud',
|
|
40
|
+
cursor: 'Cursor',
|
|
41
|
+
grok: 'Grok',
|
|
42
|
+
github: 'Copilot',
|
|
43
|
+
'github-copilot': 'Copilot',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A provider type's name as the list pill and the question card say it; ''
|
|
47
|
+
// when unknown. The relay's question push uses it too
|
|
48
|
+
// (services/push-dispatch-service.mjs).
|
|
49
|
+
export function providerDisplayName(providerType = '') {
|
|
50
|
+
return PROVIDER_NAMES[String(providerType || '').trim().toLowerCase()] || '';
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
function conversationProviderName(conversation = null) {
|
|
37
|
-
|
|
38
|
-
if (providerType === 'openai') return 'OpenAI';
|
|
39
|
-
if (providerType === 'claude') return 'Claude';
|
|
40
|
-
if (providerType === 'claude-cloud') return 'Claude Cloud';
|
|
41
|
-
if (providerType === 'cursor') return 'Cursor';
|
|
42
|
-
if (providerType === 'grok') return 'Grok';
|
|
43
|
-
if (providerType === 'github' || providerType === 'github-copilot') return 'Copilot';
|
|
44
|
-
return '';
|
|
54
|
+
return providerDisplayName(resolveConversationProviderType(conversation));
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
// An unresolved provider stays unlabelled rather than defaulting to Copilot, so
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Browser mirror of questionAcceptsFreeText in shared/question-answer-rule.mjs
|
|
2
|
+
// (the relay's answer route applies the same rule). The browser cannot import
|
|
3
|
+
// shared/ (only server/public is served), so this is a hand-kept copy;
|
|
4
|
+
// question-answer-rule.test.mjs asserts it behaves exactly like the original.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a card takes typed text. A card with choices takes it only when it
|
|
8
|
+
* says so (`allowFreeform: true`); a missing flag counts as false there, as
|
|
9
|
+
* the create route and the relay's question rows read it. Without choices a
|
|
10
|
+
* card always does: it could not be answered otherwise.
|
|
11
|
+
*/
|
|
12
|
+
export function questionAcceptsFreeText(question) {
|
|
13
|
+
const choices = Array.isArray(question?.choices) ? question.choices : [];
|
|
14
|
+
return question?.allowFreeform === true || choices.length === 0;
|
|
15
|
+
}
|
|
@@ -14,14 +14,21 @@
|
|
|
14
14
|
// answer is the selected labels joined with ", " — plus whatever the user
|
|
15
15
|
// typed as an extra answer — which is Claude's own multi-select answer format.
|
|
16
16
|
|
|
17
|
+
import { questionAcceptsFreeText } from './question-answer-rule.mjs';
|
|
18
|
+
|
|
17
19
|
function choiceCount(question) {
|
|
18
20
|
return Array.isArray(question?.choices) ? question.choices.length : 0;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Whether the card offers the "Select several" switch. Never on a card that
|
|
25
|
+
* takes no typed text: the relay takes several choices there only when the
|
|
26
|
+
* provider flagged the card multi-select.
|
|
27
|
+
*/
|
|
22
28
|
export function offersMultiSelectToggle(question) {
|
|
23
29
|
if (!question || typeof question !== 'object') return false;
|
|
24
|
-
return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true
|
|
30
|
+
return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true
|
|
31
|
+
&& questionAcceptsFreeText(question);
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
/**
|
|
@@ -24,6 +24,7 @@ const EVENT_TYPES = new Set([
|
|
|
24
24
|
'heartbeat',
|
|
25
25
|
'link_lost',
|
|
26
26
|
'link_restored',
|
|
27
|
+
'relay_restarted',
|
|
27
28
|
]);
|
|
28
29
|
const URGENT_TYPES = new Set(['question_asked', 'error']);
|
|
29
30
|
const DIGEST_PATTERN = /^Digest of \d+ events?/;
|
|
@@ -43,6 +44,7 @@ const TYPE_LABELS = Object.freeze({
|
|
|
43
44
|
heartbeat: 'heartbeat',
|
|
44
45
|
link_lost: 'link lost',
|
|
45
46
|
link_restored: 'link back',
|
|
47
|
+
relay_restarted: 'relay restarted',
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
function defaultEscape(value) {
|
|
@@ -181,6 +183,8 @@ export function watchersOf(subscriptions, conversationId, { includeAll = true }
|
|
|
181
183
|
export function watchingChipText(summary) {
|
|
182
184
|
if (!summary?.subscriptions?.length) return '';
|
|
183
185
|
if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
|
|
186
|
+
// Only relay_restarted: no session is watched.
|
|
187
|
+
if (!summary.count) return 'waiting for a relay restart';
|
|
184
188
|
return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
|
|
185
189
|
}
|
|
186
190
|
|
|
@@ -198,7 +202,7 @@ function describeSubscription(sub, conversations) {
|
|
|
198
202
|
const listed = (Array.isArray(sub.sessions) ? sub.sessions : []).filter((session) => session?.id).map(titleOf).join(', ');
|
|
199
203
|
const what = sub.scope === 'all'
|
|
200
204
|
? (paired ? `All sessions of ${relay}` : 'All sessions')
|
|
201
|
-
: (paired ? `${relay} · ${listed}` : listed);
|
|
205
|
+
: (paired ? `${relay} · ${listed}` : (listed || 'This relay'));
|
|
202
206
|
const extras = [
|
|
203
207
|
sub.once ? 'once' : '',
|
|
204
208
|
sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
|
package/server/public/index.html
CHANGED
|
@@ -5905,6 +5905,10 @@
|
|
|
5905
5905
|
color: var(--muted);
|
|
5906
5906
|
font-size: var(--fs-meta-xs);
|
|
5907
5907
|
}
|
|
5908
|
+
.relay-question-header {
|
|
5909
|
+
font-weight: 600;
|
|
5910
|
+
color: var(--text);
|
|
5911
|
+
}
|
|
5908
5912
|
.relay-question-body {
|
|
5909
5913
|
line-height: 1.5;
|
|
5910
5914
|
color: var(--text);
|
|
@@ -7190,7 +7194,7 @@
|
|
|
7190
7194
|
checked
|
|
7191
7195
|
>
|
|
7192
7196
|
</div>
|
|
7193
|
-
<div class="settings-help" style="margin-top: 6px;">
|
|
7197
|
+
<div class="settings-help" style="margin-top: 6px;">Shows or hides the menu action in this browser only. Suspend works only when the relay runs on Windows; a queued suspend still shows its banner here either way.</div>
|
|
7194
7198
|
<div class="settings-row" style="margin-top: 12px; margin-bottom: 0;">
|
|
7195
7199
|
<label for="scroll-on-send-toggle">Jump to the end when I send a message</label>
|
|
7196
7200
|
<input
|
|
@@ -7232,7 +7236,7 @@
|
|
|
7232
7236
|
onchange="updateTurnCeilingSetting(this.value)"
|
|
7233
7237
|
>
|
|
7234
7238
|
</div>
|
|
7235
|
-
<div class="settings-help" style="margin-top: 6px;">A hard cap on how long one turn may run before the relay requeues it. Turns are already kept alive by worker heartbeats, and a turn waiting on your answer to a question
|
|
7239
|
+
<div class="settings-help" style="margin-top: 6px;">A hard cap on how long one turn may run before the relay requeues it, counted from the start of the turn or from your last answer to one of its questions. Turns are already kept alive by worker heartbeats, and the relay never cuts off a turn waiting on your answer to a question — so this only catches a worker that has hung. Grok also enforces the limit itself, counted from the start of the prompt and without pausing for questions (remote relay approvals). Set to 0 for no limit.</div>
|
|
7236
7240
|
<div class="settings-row settings-row-column" style="margin-top: 12px; margin-bottom: 0;">
|
|
7237
7241
|
<label for="background-task-timeout-slider">Background task timeout <span id="background-task-timeout-value">4 h</span></label>
|
|
7238
7242
|
<input
|
|
@@ -7489,7 +7493,7 @@
|
|
|
7489
7493
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
|
7490
7494
|
<button id="copilot-save-btn" type="button" class="summary-btn" onclick="saveCopilotSettings()">Save Copilot settings</button>
|
|
7491
7495
|
</div>
|
|
7492
|
-
<div class="settings-help">Chooses how GitHub Copilot conversations run. <strong>SDK</strong> is the default: a headless worker per conversation with mid-turn steering and background task cards — no CLI extension to install, but no tmux inspector for those sessions. <strong>Extension</strong> runs the Copilot CLI in a terminal session, which you can attach to with the tmux inspector. A relay that cannot run the SDK engine uses Extension. Applies
|
|
7496
|
+
<div class="settings-help">Chooses how GitHub Copilot conversations run. <strong>SDK</strong> is the default: a headless worker per conversation with mid-turn steering and background task cards — no CLI extension to install, but no tmux inspector for those sessions. <strong>Extension</strong> runs the Copilot CLI in a terminal session, which you can attach to with the tmux inspector. A relay where no engine was saved and that cannot run the SDK engine uses Extension. Applies whenever a conversation's worker starts; sessions already running keep their engine until their worker restarts.</div>
|
|
7493
7497
|
</div>
|
|
7494
7498
|
</div>
|
|
7495
7499
|
<div
|
|
@@ -7883,7 +7887,7 @@
|
|
|
7883
7887
|
<button id="cursor-dashboard-token-save-btn" type="button" class="summary-btn" onclick="saveCursorDashboardToken()">Save token</button>
|
|
7884
7888
|
<button id="cursor-dashboard-token-remove-btn" type="button" class="summary-btn" onclick="removeCursorDashboardToken()">Remove</button>
|
|
7885
7889
|
</div>
|
|
7886
|
-
<div class="settings-help">Unlocks the live "
|
|
7890
|
+
<div class="settings-help">Unlocks the live percentage bars on Check Usage: "Plan usage (total)", "Auto + Composer" and "API". Detected automatically from this machine's Cursor IDE login when one exists; paste the <code>WorkosCursorSessionToken</code> cookie from a logged-in cursor.com tab (DevTools → Application → Cookies) only for headless relay hosts or to use a different account. Headless hosts can also set <code>CURSOR_SESSION_TOKEN</code> in the relay environment. Tokens stay on the relay.</div>
|
|
7887
7891
|
</div>
|
|
7888
7892
|
</div>
|
|
7889
7893
|
</div>
|
|
@@ -7929,7 +7933,7 @@
|
|
|
7929
7933
|
checked
|
|
7930
7934
|
>
|
|
7931
7935
|
</div>
|
|
7932
|
-
<div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still read sessions here, but cannot send prompts, start sessions, answer questions or
|
|
7936
|
+
<div class="settings-help" style="margin-top: 6px;">When off, agents on paired relays can still list, read and wait on sessions here, but cannot send prompts, start sessions, answer questions, stop turns, cancel queued turns, archive or unarchive sessions, or watch sessions here for events. The relay token stays the real boundary: anyone holding it has full access.</div>
|
|
7933
7937
|
<!-- Agent sessions (agent-sessions-settings-ui.js). Hidden until the relay
|
|
7934
7938
|
answers with settings; a relay without the route never shows it. -->
|
|
7935
7939
|
<div id="agent-sessions-section" hidden>
|
|
@@ -2,3 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// Intentional relay restart requested via authenticated localhost API.
|
|
4
4
|
export const RELAY_RESTART_EXIT_CODE = 75;
|
|
5
|
+
// The runtime stopped on purpose and starting it again at once cannot help:
|
|
6
|
+
// its database needs a decision (adoptEarlierDatabase refused), or its data
|
|
7
|
+
// folder cannot be made (ensureDataDirs). The supervisors do not retry it, so
|
|
8
|
+
// its one line is said once; the code goes on to whatever started the relay.
|
|
9
|
+
// 78 is sysexits' EX_CONFIG.
|
|
10
|
+
export const RELAY_STOP_EXIT_CODE = 78;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { RELAY_RESTART_EXIT_CODE } from './relay-exit-codes.mjs';
|
|
3
|
+
import { RELAY_RESTART_EXIT_CODE, RELAY_STOP_EXIT_CODE } from './relay-exit-codes.mjs';
|
|
4
4
|
|
|
5
5
|
// Role selection travels on argv, never in the environment. The runtime's env is
|
|
6
6
|
// inherited by tmux worker sessions and by the Copilot CLI it launches, and the
|
|
@@ -151,6 +151,11 @@ export async function runDirectRelaySupervisor({
|
|
|
151
151
|
return await exitImpl(0);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
// Stopped on purpose, with its reason already said: not a crash.
|
|
155
|
+
if (exitCode === RELAY_STOP_EXIT_CODE) {
|
|
156
|
+
return await exitImpl(RELAY_STOP_EXIT_CODE);
|
|
157
|
+
}
|
|
158
|
+
|
|
154
159
|
const attempt = crashCount + 1;
|
|
155
160
|
logger?.error?.(`[relay] runtime crashed with exit code ${exitCode ?? 'null'} (crash ${attempt}/${maxCrashRestarts}).`);
|
|
156
161
|
if (attempt >= maxCrashRestarts) {
|
package/server/relay-tools.md
CHANGED
|
@@ -2,51 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
For any user-facing question or clarification, use the ask_user tool so the web relay can render question cards and buttons. Never ask questions in plain assistant text.
|
|
4
4
|
|
|
5
|
-
When using ask_user, ALWAYS include a `choices` array with 2-
|
|
5
|
+
When using ask_user, ALWAYS include a `choices` array with 2-8 answer options so the web relay can render clickable buttons. The relay keeps the first 8 choices and drops the rest. Example:
|
|
6
6
|
```json
|
|
7
7
|
{
|
|
8
|
-
"question": "
|
|
9
|
-
"choices": ["
|
|
8
|
+
"question": "The login test fails because the session cookie expires too early. How should I fix it?",
|
|
9
|
+
"choices": ["Fix the cookie lifetime in the code", "Update the test to match the code", "Leave it and only report it"]
|
|
10
10
|
}
|
|
11
11
|
```
|
|
12
|
-
At runtime, inject the question and choices from a random item in your quiz pool.
|
|
13
12
|
Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
|
|
14
13
|
|
|
15
14
|
When the user may pick several of the choices (they are not mutually exclusive), set `"multi_select": true` if your ask_user tool offers that field; otherwise begin the question with "Select all that apply:". Either way, expect an answer that lists several choices separated by ", ".
|
|
16
15
|
|
|
17
16
|
In autopilot, still call ask_user when user input is truly blocking, because the relay bridge can surface the question even when the direct SDK question hook is bypassed.
|
|
18
17
|
|
|
19
|
-
For relay
|
|
18
|
+
For a relay restart or shutdown, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.
|
|
20
19
|
|
|
21
|
-
Note:
|
|
20
|
+
Note: the request is queued. The relay only shuts down or restarts once no turn is running on it, your own included (after a maximum wait it asks the user), and it holds new messages until then, so end your turn instead of waiting for the restart. The answer names the sessions it waits for (`waitingFor`), the messages it holds (`held`) and the background tasks (`backgroundTasks`, each with whether it `survives`): tell the user.
|
|
22
21
|
|
|
23
22
|
Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.
|
|
24
23
|
|
|
25
24
|
Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.
|
|
26
25
|
|
|
26
|
+
To go on with your work once the relay is back, subscribe first with `remote_relay` `{"action":"subscribe","events":["relay_restarted"],"once":true}` (no sessions needed), then request the restart: your own timers and scheduled jobs may not survive it. A restart requested while a turn that `relay_restarted` woke is still running does not wake that conversation (a `once` subscription ends with it).
|
|
27
|
+
|
|
27
28
|
## Preview servers
|
|
28
29
|
|
|
29
|
-
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close
|
|
30
|
+
Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close this conversation's previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Publishing the same port or directory again in this conversation returns the same link. Previews never expire and survive a relay restart (a port preview until the machine reboots); close them with {action:"close"} when the user is done.
|
|
30
31
|
|
|
31
|
-
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews
|
|
32
|
+
If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews?conversationId=<conv>` lists this conversation's previews, `DELETE /api/previews/:token?conversationId=<conv>` closes one of them without touching the dev server behind it. Always pass the conversation: you may list and close only this conversation's previews, another conversation's is refused (403), and the relay-wide list is the user's (Settings → Previews). A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
|
|
32
33
|
|
|
33
34
|
## Remote relays
|
|
34
35
|
|
|
35
36
|
If you have a `remote_relay` tool, it works with sessions on OAR relays: the other relays this relay is paired with and, when its owner allows it, this relay itself. It lists and reads sessions, prompts a session, starts a new one and waits for its reply. Start with `{"action":"list_relays"}`.
|
|
36
37
|
|
|
37
|
-
A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it.
|
|
38
|
+
A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name) or an agent on that relay writes to this conversation; a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it, or to answer an agent on it that wrote to this conversation.
|
|
38
39
|
|
|
39
40
|
This relay itself is listed with "self": true when its owner switched agent sessions on, and is addressed by its name or as "this"; it needs no mention. Use it to hand parts of your task to other sessions:
|
|
40
41
|
|
|
41
42
|
- `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
|
|
42
43
|
- The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only).
|
|
43
44
|
- Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. Agents only read groups, the user arranges them.
|
|
44
|
-
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches (
|
|
45
|
+
- A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
|
|
45
46
|
- A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
|
|
46
47
|
- Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
|
|
47
48
|
- Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
|
|
48
49
|
- `read_task_output` ("session", "task_id" from `read_session`'s tasks, optional "lines") reads the end of a background shell or Monitor task's output, also for an hour after it ended: the last lines, minutes since its last output, and pass/fail counts for a test run. Credentials are scrubbed out.
|
|
49
50
|
|
|
50
|
-
On this relay and on a paired one, `subscribe` wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command.
|
|
51
|
+
On this relay and on a paired one, `subscribe` ("sessions": ids, or ["all"]) wakes you when something happens in the sessions you watch, instead of polling them. Leave "events" out unless you need more: the default (question_asked, error, turn_end, subagent_finished, task_failed, stalled, all_idle) wakes you for questions, errors, replies, finished sub-agents, failed tasks (a non-zero exit included), stalls and all_idle, but not for each shell command that ended well. To get those too, pass that list plus "task_finished" (and "task_started" for the starts); each one costs you a turn, so do it only if you must follow every command. "relay_restarted" (this relay only, never in the default) wakes you when the relay runs again after a restart, with who requested it and why; named alone it needs no sessions.
|
|
52
|
+
|
|
53
|
+
The first `subscribe` of a conversation on a relay, and any to "all", shows the user an approval card. `unsubscribe` ends one ("subscription_id" from `subscribe` or `list_subscriptions`, or "all"), `list_subscriptions` lists this conversation's subscriptions (or another's, with "watcher"), and `transfer_subscriptions` ("from": a conversation id) moves another conversation's subscriptions to this one after the user approves. `usage` shows what each provider plan of a relay has left and whether a Claude usage limit has paused turns there: check it before you start work that costs. `stop` stops a session's running turn and `archive` archives a session. The `remote_relay` tool's own description is the full reference for every action and field.
|
|
51
54
|
|
|
52
55
|
Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
|