@oar-sh/oar 0.9.5 → 0.9.7
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 +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -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/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -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-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- 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 +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- 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 +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- 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/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -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
|
@@ -23,7 +23,10 @@ export function createControlPoller({
|
|
|
23
23
|
onAbortSubagent = null,
|
|
24
24
|
dbg = () => {},
|
|
25
25
|
} = {}) {
|
|
26
|
-
|
|
26
|
+
// The turns a Stop is polled for, oldest first, and whether the one loop
|
|
27
|
+
// that serves them all is running.
|
|
28
|
+
const registrations = new Set();
|
|
29
|
+
let polling = false;
|
|
27
30
|
|
|
28
31
|
async function checkOnce({ queueMessageId, onAbortTurn }) {
|
|
29
32
|
const ownerSessionId = String(sdkSessionId || '').trim();
|
|
@@ -81,37 +84,83 @@ export function createControlPoller({
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Hand a Stop to the turns that polled for it, newest first, until one of
|
|
89
|
+
* them takes it. The candidates were chosen before the request went out and
|
|
90
|
+
* are not re-checked here: the relay has claimed the control for this worker
|
|
91
|
+
* by now, so a turn that finished meanwhile still has to answer it.
|
|
92
|
+
*/
|
|
93
|
+
async function offerAbort(candidates, control) {
|
|
94
|
+
let failure = null;
|
|
95
|
+
for (const registration of candidates) {
|
|
96
|
+
try {
|
|
97
|
+
await registration.onAbortTurn(control);
|
|
98
|
+
return;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
failure = error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
throw failure || new Error('abort failed');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function pollLoop() {
|
|
107
|
+
while (registrations.size) {
|
|
108
|
+
await sleep(pollMs);
|
|
109
|
+
// One request per row filter, however many turns share it, asked in the
|
|
110
|
+
// order of the newest turn that uses it.
|
|
111
|
+
const byFilter = new Map();
|
|
112
|
+
for (const registration of [...registrations].reverse()) {
|
|
113
|
+
const candidates = byFilter.get(registration.queueMessageId) || [];
|
|
114
|
+
candidates.push(registration);
|
|
115
|
+
byFilter.set(registration.queueMessageId, candidates);
|
|
116
|
+
}
|
|
117
|
+
for (const [queueMessageId, candidates] of byFilter) {
|
|
118
|
+
// Every turn behind this filter ended while an earlier one was asked.
|
|
119
|
+
if (candidates.every((registration) => registration.stopped)) continue;
|
|
92
120
|
try {
|
|
93
|
-
|
|
94
|
-
if (aborted) return;
|
|
121
|
+
await checkOnce({ queueMessageId, onAbortTurn: (control) => offerAbort(candidates, control) });
|
|
95
122
|
} catch (error) {
|
|
96
123
|
dbg('control poll failed', error?.message || String(error));
|
|
97
124
|
}
|
|
98
125
|
}
|
|
99
|
-
}
|
|
100
|
-
|
|
126
|
+
}
|
|
127
|
+
polling = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Poll for a Stop on behalf of one turn, until `stop(handle)`. Turns overlap
|
|
132
|
+
* (a settled turn still waiting on a question card or publishing, beside the
|
|
133
|
+
* one that runs), so a new registration never displaces an older one, and a
|
|
134
|
+
* handled Stop does not end the polling: the Stop may have been for another
|
|
135
|
+
* turn's row, and the turns that remain still need theirs.
|
|
136
|
+
*/
|
|
137
|
+
function start({ queueMessageId, onAbortTurn }) {
|
|
138
|
+
const registration = {
|
|
139
|
+
stopped: false,
|
|
140
|
+
queueMessageId: String(queueMessageId || ''),
|
|
141
|
+
onAbortTurn,
|
|
142
|
+
};
|
|
143
|
+
registrations.add(registration);
|
|
144
|
+
if (!polling) {
|
|
145
|
+
polling = true;
|
|
146
|
+
void pollLoop();
|
|
147
|
+
}
|
|
148
|
+
return registration;
|
|
101
149
|
}
|
|
102
150
|
|
|
103
151
|
function stop(handle) {
|
|
104
|
-
// Handle-scoped stop: a caller finishing
|
|
105
|
-
//
|
|
106
|
-
// context would
|
|
107
|
-
// its Stop button dead. A bare stop()
|
|
152
|
+
// Handle-scoped stop: a caller finishing a turn ends that turn's
|
|
153
|
+
// registration and no other. Without the scoping, a late finalize for a
|
|
154
|
+
// previous context would end the polling for the turn currently running,
|
|
155
|
+
// leaving its Stop button dead. A bare stop() ends every registration (the
|
|
156
|
+
// worker is shutting down).
|
|
108
157
|
if (handle) {
|
|
109
158
|
handle.stopped = true;
|
|
110
|
-
|
|
159
|
+
registrations.delete(handle);
|
|
111
160
|
return;
|
|
112
161
|
}
|
|
113
|
-
|
|
114
|
-
|
|
162
|
+
for (const registration of registrations) registration.stopped = true;
|
|
163
|
+
registrations.clear();
|
|
115
164
|
}
|
|
116
165
|
|
|
117
166
|
return { start, stop, checkOnce };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// What makes a text a failure note of the relay.
|
|
2
|
+
//
|
|
3
|
+
// A failed turn is reported as a record. Where only text is there (a sender
|
|
4
|
+
// that reports a failure as text, a reply read from another relay), the note
|
|
5
|
+
// is known by its shape: one paragraph that leads up to "Error code:
|
|
6
|
+
// relay.<code>" within a few sentences. A reply that quotes or describes such
|
|
7
|
+
// a note, in a report on a failed test for example, is a reply.
|
|
8
|
+
//
|
|
9
|
+
// One rule for every reader: the relay's response route and the remote-relay
|
|
10
|
+
// dispatcher must not disagree about what a failure is.
|
|
11
|
+
|
|
12
|
+
export const FAILURE_NOTE_LEAD_MAX = 600;
|
|
13
|
+
|
|
14
|
+
const CODE_PATTERN = /error code:\s*(relay\.[a-z0-9-]+)/i;
|
|
15
|
+
// Somebody is talking about a note: the code stands in quotation marks, in a
|
|
16
|
+
// code span or a table cell, or the paragraph is a list item, a heading or a
|
|
17
|
+
// quotation.
|
|
18
|
+
const QUOTED_LEAD = /["“”'`|>]\s*$/;
|
|
19
|
+
const MARKUP_LEAD = /^\s*(?:#+|[>|*-]|\d+\.)\s/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `{ stableCode, index, lead }` when `text` is a failure note, else null.
|
|
23
|
+
* `index` is where "Error code" begins, `lead` the text before it.
|
|
24
|
+
*/
|
|
25
|
+
export function matchFailureNote(text) {
|
|
26
|
+
const raw = String(text ?? '').trim();
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
const match = CODE_PATTERN.exec(raw);
|
|
29
|
+
if (!match) return null;
|
|
30
|
+
const lead = raw.slice(0, match.index);
|
|
31
|
+
if (lead.length > FAILURE_NOTE_LEAD_MAX || /[\r\n]/.test(lead)) return null;
|
|
32
|
+
if (QUOTED_LEAD.test(lead) || MARKUP_LEAD.test(lead)) return null;
|
|
33
|
+
return { stableCode: match[1].toLowerCase(), index: match.index, lead };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isFailureNoteText(text) {
|
|
37
|
+
return matchFailureNote(text) !== null;
|
|
38
|
+
}
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
export const LEGACY_RELAY_PROVIDER_TYPES = Object.freeze(['github', 'openai']);
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// `claude-cloud` is its own provider, never an alias of `claude`: its worker
|
|
15
|
+
// drives a session in Anthropic's cloud and spawns no CLI.
|
|
16
|
+
export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud']);
|
|
15
17
|
|
|
16
18
|
export function isLegacyRelayProviderType(value) {
|
|
17
19
|
const normalized = String(value || '').trim().toLowerCase();
|
|
@@ -23,7 +25,7 @@ export function isSessionWorkerProviderType(value) {
|
|
|
23
25
|
return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/** `'claude', 'cursor', 'grok'` — for interpolation into SQL NOT IN (...) lists. */
|
|
28
|
+
/** `'claude', 'cursor', 'grok', 'claude-cloud'` — for interpolation into SQL NOT IN (...) lists. */
|
|
27
29
|
export function sessionWorkerProviderSqlList() {
|
|
28
30
|
return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
|
|
29
31
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Waiting for the answer to a question card, and creating the card, through
|
|
2
|
+
// a relay that is briefly not there.
|
|
3
|
+
//
|
|
4
|
+
// A card may be open for hours and is polled every 1.5 s. One poll that failed
|
|
5
|
+
// — the relay restarting, a reset connection, a 502 from a proxy in front of
|
|
6
|
+
// it — used to end the wait with an error, and the callers turn an error into
|
|
7
|
+
// an answer: "continue with your best judgement" for a question, a rejection
|
|
8
|
+
// for an approval. The card itself stayed open, so the user later answered a
|
|
9
|
+
// question nobody was waiting on any more. The relay keeps its cards across a
|
|
10
|
+
// restart; the wait only has to outlast it.
|
|
11
|
+
|
|
12
|
+
import { QUESTION_TIMEOUT_CONTINUATION_TEXT } from './question-timeout.mjs';
|
|
13
|
+
import { isRelayUnreachableError, isTransientRelayError } from './worker-runtime/relay-errors.mjs';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_POLL_MS = 1500;
|
|
16
|
+
/** The spacing between polls grows to this while the relay does not answer. */
|
|
17
|
+
const MAX_OUTAGE_POLL_MS = 10_000;
|
|
18
|
+
/** Attempts to create a card while the relay refuses connections (~16 s). */
|
|
19
|
+
const DEFAULT_CREATE_RETRY_DELAYS_MS = Object.freeze([500, 1_000, 2_000, 4_000, 8_000]);
|
|
20
|
+
|
|
21
|
+
function sleepDefault(ms) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a question card. Retried only while the relay refuses the
|
|
27
|
+
* connection: any other failure may have created the card, and a second
|
|
28
|
+
* request would put a second one in front of the user.
|
|
29
|
+
*/
|
|
30
|
+
export async function createRelayQuestion({
|
|
31
|
+
api, payload, sleep = sleepDefault, retryDelaysMs = DEFAULT_CREATE_RETRY_DELAYS_MS, signal = null, dbg = () => {},
|
|
32
|
+
} = {}) {
|
|
33
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
const created = await api('POST', '/api/relay-question', payload);
|
|
36
|
+
const questionId = created?.question?.id;
|
|
37
|
+
if (!questionId) throw new Error('Relay question could not be created');
|
|
38
|
+
return questionId;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!isRelayUnreachableError(error) || attempt >= retryDelaysMs.length || signal?.aborted) throw error;
|
|
41
|
+
dbg('relay unreachable while creating a question; trying again', error?.message || String(error));
|
|
42
|
+
await sleep(retryDelaysMs[attempt]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Poll a card until it is answered, times out, is cancelled, or `signal`
|
|
49
|
+
* aborts the wait. Resolves `{ answer, structuredAnswer, timedOut, aborted? }`.
|
|
50
|
+
* Rejects only on the relay's own refusal (a 4xx, a card that does not exist).
|
|
51
|
+
*/
|
|
52
|
+
export async function waitForRelayQuestion({
|
|
53
|
+
api,
|
|
54
|
+
questionId,
|
|
55
|
+
deadlineMs,
|
|
56
|
+
pollMs = DEFAULT_POLL_MS,
|
|
57
|
+
sleep = sleepDefault,
|
|
58
|
+
signal = null,
|
|
59
|
+
trimAnswer = true,
|
|
60
|
+
now = Date.now,
|
|
61
|
+
dbg = () => {},
|
|
62
|
+
} = {}) {
|
|
63
|
+
const started = now();
|
|
64
|
+
const expired = { answer: QUESTION_TIMEOUT_CONTINUATION_TEXT, structuredAnswer: null, timedOut: true };
|
|
65
|
+
let failedPolls = 0;
|
|
66
|
+
while (true) {
|
|
67
|
+
if (signal?.aborted) {
|
|
68
|
+
await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
|
|
69
|
+
return { ...expired, aborted: true };
|
|
70
|
+
}
|
|
71
|
+
let question = null;
|
|
72
|
+
let outage = null;
|
|
73
|
+
try {
|
|
74
|
+
({ question } = await api('GET', `/api/relay-question/${questionId}`));
|
|
75
|
+
if (!question) throw Object.assign(new Error('Relay question missing'), { status: 404 });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!isTransientRelayError(error)) throw error;
|
|
78
|
+
outage = error;
|
|
79
|
+
}
|
|
80
|
+
if (outage) {
|
|
81
|
+
failedPolls += 1;
|
|
82
|
+
if (failedPolls === 1) dbg('relay did not answer the question poll; the card stays open', questionId, outage?.message || String(outage));
|
|
83
|
+
} else {
|
|
84
|
+
if (failedPolls) dbg('relay answers the question poll again', questionId, `after ${failedPolls} failed poll(s)`);
|
|
85
|
+
failedPolls = 0;
|
|
86
|
+
if (question.status === 'answered') {
|
|
87
|
+
const answer = String(question.answer || '');
|
|
88
|
+
return {
|
|
89
|
+
answer: trimAnswer ? answer.trim() : answer,
|
|
90
|
+
// The validated structured submission, when the card carried a
|
|
91
|
+
// `requestedSchema` and the relay stored one. Null otherwise — flat
|
|
92
|
+
// consumers read `answer` and never see a shape change.
|
|
93
|
+
structuredAnswer: question.structuredAnswer && typeof question.structuredAnswer === 'object'
|
|
94
|
+
&& !Array.isArray(question.structuredAnswer)
|
|
95
|
+
? question.structuredAnswer
|
|
96
|
+
: null,
|
|
97
|
+
timedOut: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (question.status === 'timed_out' || question.status === 'cancelled') return { ...expired };
|
|
101
|
+
}
|
|
102
|
+
if (now() - started >= deadlineMs) {
|
|
103
|
+
await api('POST', `/api/relay-question/${questionId}/timeout`, {}).catch(() => {});
|
|
104
|
+
return { ...expired };
|
|
105
|
+
}
|
|
106
|
+
await sleep(failedPolls ? Math.min(MAX_OUTAGE_POLL_MS, pollMs * 2 ** Math.min(failedPolls, 6)) : pollMs);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
// provenance ("origin") shape a relay attaches when its agent prompts a remote
|
|
8
8
|
// session, the prompt header line, and the URL policy for remote addresses.
|
|
9
9
|
//
|
|
10
|
+
// Since agent sessions, the relay an agent runs on is a target as well (the
|
|
11
|
+
// "local target", addressed by this relay's name or `this`): the same tool
|
|
12
|
+
// creates and drives sessions on its own relay, behind a setting.
|
|
13
|
+
//
|
|
10
14
|
// SDK-free and side-effect free: imported by the server, the session workers,
|
|
11
15
|
// the stdio MCP server and (for the header/URL helpers) the browser.
|
|
12
16
|
|
|
17
|
+
import { isValidBranchName, normalizeGitHubRepoUrl } from './claude-cloud/repo-url.mjs';
|
|
18
|
+
|
|
13
19
|
export const REMOTE_RELAY_TOOL_NAME = 'remote_relay';
|
|
14
20
|
|
|
15
21
|
// Bumped when a relay starts relying on a newer inbound behaviour of its peers.
|
|
@@ -54,10 +60,13 @@ export const REMOTE_RELAY_ACTION_PERMISSION = Object.freeze({
|
|
|
54
60
|
// the calling session runs in ask or plan mode.
|
|
55
61
|
export const REMOTE_RELAY_WRITE_ACTIONS = Object.freeze(['send', 'create_session', 'answer_question', 'stop', 'archive']);
|
|
56
62
|
|
|
63
|
+
// The write actions that carry an agent's words to another relay's agent.
|
|
64
|
+
export const REMOTE_RELAY_PROMPT_ACTIONS = Object.freeze(['send', 'create_session', 'answer_question']);
|
|
65
|
+
|
|
57
66
|
export const REMOTE_RELAY_APPROVAL_MODES = Object.freeze(['ask', 'plan']);
|
|
58
67
|
|
|
59
68
|
export const REMOTE_RELAY_SESSION_SCOPES = Object.freeze(['active', 'recent', 'all']);
|
|
60
|
-
export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok']);
|
|
69
|
+
export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok', 'claude-cloud']);
|
|
61
70
|
export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
|
|
62
71
|
export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
|
|
63
72
|
// Reasoning efforts differ per provider and model (none, low, medium, high,
|
|
@@ -78,7 +87,16 @@ export const REMOTE_RELAY_RESULT_STATUSES = Object.freeze([
|
|
|
78
87
|
|
|
79
88
|
export const REMOTE_RELAY_LIMITS = Object.freeze({
|
|
80
89
|
waitDefaultSeconds: 120,
|
|
90
|
+
// The longest wait of one call is a setting of the relay ("Longest wait per
|
|
91
|
+
// tool call"): waitMaxSeconds is its default, the next three its range. The
|
|
92
|
+
// validator below clamps to the ceiling; the relay clamps to its setting.
|
|
81
93
|
waitMaxSeconds: 600,
|
|
94
|
+
waitMaxFloorSeconds: 120,
|
|
95
|
+
waitMaxCeilingSeconds: 3600,
|
|
96
|
+
waitMaxStepSeconds: 60,
|
|
97
|
+
// Sessions one conversation's agent started on its own relay that may have
|
|
98
|
+
// a turn queued or running at the same time.
|
|
99
|
+
localActiveSessions: 4,
|
|
82
100
|
listDefault: 20,
|
|
83
101
|
listMax: 100,
|
|
84
102
|
readDefault: 10,
|
|
@@ -87,7 +105,12 @@ export const REMOTE_RELAY_LIMITS = Object.freeze({
|
|
|
87
105
|
maxCharsMax: 50000,
|
|
88
106
|
perMessageChars: 4000,
|
|
89
107
|
textMax: 100000,
|
|
108
|
+
// Only toward a relay the user did not unlock and that is not the one the
|
|
109
|
+
// prompt came from: a third relay.
|
|
90
110
|
hopLimit: 2,
|
|
111
|
+
// Prompts a conversation's agent may send to other relays' agents without a
|
|
112
|
+
// message from the user in between, before the user is asked.
|
|
113
|
+
agentPromptsBeforeAsking: 30,
|
|
91
114
|
callsPerMinute: 60,
|
|
92
115
|
writesPerMinute: 10,
|
|
93
116
|
healthIntervalMs: 60_000,
|
|
@@ -111,6 +134,15 @@ export const REMOTE_RELAY_ERROR_CODES = Object.freeze({
|
|
|
111
134
|
providerUnavailable: 'REMOTE_RELAY_PROVIDER_UNAVAILABLE',
|
|
112
135
|
busy: 'REMOTE_RELAY_BUSY',
|
|
113
136
|
noTurn: 'REMOTE_RELAY_NO_ACTIVE_TURN',
|
|
137
|
+
// The local target (this relay itself):
|
|
138
|
+
// the setting "Agents may start and use sessions on this relay" is off,
|
|
139
|
+
localDisabled: 'REMOTE_RELAY_LOCAL_DISABLED',
|
|
140
|
+
// a session an agent created tried to create one itself,
|
|
141
|
+
nestedSession: 'REMOTE_RELAY_NESTED_SESSION',
|
|
142
|
+
// the conversation already has `localActiveSessions` created sessions at work,
|
|
143
|
+
sessionLimit: 'REMOTE_RELAY_SESSION_LIMIT',
|
|
144
|
+
// and an agent tried to prompt, stop, archive, wait for or answer itself.
|
|
145
|
+
ownSession: 'REMOTE_RELAY_OWN_SESSION',
|
|
114
146
|
internal: 'REMOTE_RELAY_INTERNAL',
|
|
115
147
|
// Prefix: the remote answered with this HTTP status, e.g. REMOTE_RELAY_HTTP_409.
|
|
116
148
|
httpPrefix: 'REMOTE_RELAY_HTTP_',
|
|
@@ -123,6 +155,10 @@ export const REMOTE_RELAY_SETTING_KEYS = Object.freeze({
|
|
|
123
155
|
relays: 'remote_relays',
|
|
124
156
|
publicUrl: 'relay_public_url',
|
|
125
157
|
inboundEnabled: 'remote_relay_inbound_enabled',
|
|
158
|
+
// Agent sessions: the local target's switch (off unless set) and the longest
|
|
159
|
+
// wait of one tool call, for local and paired targets alike.
|
|
160
|
+
agentSessionsEnabled: 'agent_sessions_enabled',
|
|
161
|
+
maxWaitSeconds: 'remote_relay_max_wait_seconds',
|
|
126
162
|
});
|
|
127
163
|
|
|
128
164
|
// Headers a relay adds to every call it forwards to a remote. The remote uses
|
|
@@ -134,20 +170,42 @@ export const REMOTE_RELAY_HEADERS = Object.freeze({
|
|
|
134
170
|
});
|
|
135
171
|
|
|
136
172
|
export const REMOTE_RELAY_SOCKET_EVENT = 'remote_relays_updated';
|
|
173
|
+
// Carries the payload of GET /api/settings/agent-sessions.
|
|
174
|
+
export const AGENT_SESSIONS_SOCKET_EVENT = 'agent_sessions_settings_updated';
|
|
175
|
+
|
|
176
|
+
// The local target: the alias that always names the relay the agent runs on,
|
|
177
|
+
// and the id it has wherever a paired relay has its registry id (the
|
|
178
|
+
// remembered approval is stored as an unlock under it).
|
|
179
|
+
export const REMOTE_RELAY_LOCAL_ALIAS = 'this';
|
|
180
|
+
export const REMOTE_RELAY_LOCAL_ID = 'self';
|
|
181
|
+
export const REMOTE_RELAY_CLOUD_PROVIDER = 'claude-cloud';
|
|
137
182
|
|
|
138
183
|
export const REMOTE_RELAY_TOOL_DESCRIPTION =
|
|
139
|
-
'Work with sessions on
|
|
140
|
-
+ '
|
|
141
|
-
+ 'wait for its reply, and answer or stop it.
|
|
142
|
-
+ '
|
|
143
|
-
+ '
|
|
184
|
+
'Work with sessions on OAR relays: the other relays this relay is paired with ("remote relays") '
|
|
185
|
+
+ 'and, when its owner allows it, this relay itself. List the relays, list or read their '
|
|
186
|
+
+ 'sessions, prompt a session, start a new one, wait for its reply, and answer or stop it. '
|
|
187
|
+
+ 'Start with {action:"list_relays"}. A paired relay stays locked until '
|
|
188
|
+
+ 'the user mentions it in this conversation (for example @name) or an agent on that '
|
|
189
|
+
+ 'relay writes to it; from then on it stays open for the whole conversation. A '
|
|
144
190
|
+ 'locked relay refuses everything except list_relays, so ask the user to mention it. '
|
|
191
|
+
+ 'Use a paired relay only when the user asks for work there or mentions it, or to answer '
|
|
192
|
+
+ 'an agent on it that wrote to this conversation. '
|
|
193
|
+
+ 'This relay is listed with self:true and is addressed by its name or as "this"; it needs '
|
|
194
|
+
+ 'no mention. Use it to hand parts of your task to other sessions: create_session up to '
|
|
195
|
+
+ `${REMOTE_RELAY_LIMITS.localActiveSessions} sessions that work at the same time (any provider relay_info lists: local ones `
|
|
196
|
+
+ 'with cwd, provider "claude-cloud" with repo and an optional branch), then wait for and read '
|
|
197
|
+
+ 'each one. The first create_session here shows the user an approval card once per '
|
|
198
|
+
+ 'conversation. A session you created cannot create sessions itself, and every session '
|
|
199
|
+
+ 'runs on the relay owner\'s accounts, so start only what the task needs. A new session '
|
|
200
|
+
+ 'knows nothing of this conversation: write a self-contained prompt, and for '
|
|
201
|
+
+ '"claude-cloud" (a sandbox with a fresh clone of the repository) name the branch to '
|
|
202
|
+
+ 'work on and to push. '
|
|
145
203
|
+ 'send and create_session queue the prompt and wait up to wait_seconds (default 120, '
|
|
146
|
-
+ 'max 600) for the reply. If the
|
|
204
|
+
+ 'max set on the relay, default 600) for the reply. If the turn is still running you get status '
|
|
147
205
|
+ '"running" with progress: call {action:"wait", relay, session, message_id} to keep '
|
|
148
|
-
+ 'waiting.
|
|
206
|
+
+ 'waiting. An agent that answers but keeps working in the background also comes '
|
|
149
207
|
+ 'back as "running", with its latest reply so far; "done" means it has finished. '
|
|
150
|
-
+ 'If a result lists pendingQuestions, the
|
|
208
|
+
+ 'If a result lists pendingQuestions, the other agent is waiting for an '
|
|
151
209
|
+ 'answer: ask the user (unless they told you to decide yourself) and pass the answer '
|
|
152
210
|
+ 'with answer_question. Write actions can first need the user\'s approval. Always '
|
|
153
211
|
+ 'tell the user which relay and session you worked on.';
|
|
@@ -158,21 +216,22 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
158
216
|
action: {
|
|
159
217
|
type: 'string',
|
|
160
218
|
enum: [...REMOTE_RELAY_ACTIONS],
|
|
161
|
-
description: 'list_relays:
|
|
219
|
+
description: 'list_relays: the relays (this one included when allowed) and whether each is unlocked here. relay_info: providers, models, '
|
|
162
220
|
+ 'workspaces of a relay. list_sessions / read_session: discover and read sessions. send: prompt an '
|
|
163
221
|
+ 'existing session. create_session: start a new session with a first prompt. wait: keep waiting for '
|
|
164
222
|
+ 'a reply. answer_question: answer a question the remote agent asked. stop: stop the running turn. '
|
|
165
223
|
+ 'archive: archive a session.',
|
|
166
224
|
},
|
|
167
|
-
relay: { type: 'string', description: 'Relay name (or its host) from list_relays. Required except for list_relays.' },
|
|
168
|
-
session: { type: 'string', description: '
|
|
169
|
-
text: { type: 'string', description: 'send / create_session: the prompt for the
|
|
225
|
+
relay: { type: 'string', description: 'Relay name (or its host) from list_relays, or "this" for the relay you run on. Required except for list_relays.' },
|
|
226
|
+
session: { type: 'string', description: 'Session (conversation) id from list_sessions or create_session.' },
|
|
227
|
+
text: { type: 'string', description: 'send / create_session: the prompt for the other agent.' },
|
|
170
228
|
message_id: { type: 'string', description: 'wait: the message_id that send or create_session returned.' },
|
|
171
229
|
wait_seconds: {
|
|
172
230
|
type: 'integer',
|
|
173
231
|
minimum: 0,
|
|
174
|
-
maximum: REMOTE_RELAY_LIMITS.
|
|
175
|
-
description: 'send / create_session / wait: how long to wait for the reply (default 120, 0 = do not wait
|
|
232
|
+
maximum: REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
|
|
233
|
+
description: 'send / create_session / wait: how long to wait for the reply (default 120, 0 = do not wait; '
|
|
234
|
+
+ 'the relay limits it to its own maximum, 600 unless its owner changed it).',
|
|
176
235
|
},
|
|
177
236
|
scope: {
|
|
178
237
|
type: 'string',
|
|
@@ -201,7 +260,16 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
201
260
|
description: 'create_session: model id (default: this session\'s model when the remote offers it, else the remote\'s default). '
|
|
202
261
|
+ 'send: switch the remote session to this model (default: it keeps its own).',
|
|
203
262
|
},
|
|
204
|
-
cwd: { type: 'string', description: 'create_session: working directory on the
|
|
263
|
+
cwd: { type: 'string', description: 'create_session: working directory on the target relay (default: its default workspace). Not for claude-cloud.' },
|
|
264
|
+
repo: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
description: 'create_session with provider "claude-cloud" (required there, an error with any other provider): '
|
|
267
|
+
+ 'the GitHub repository the cloud session clones, as https://github.com/owner/repo or owner/repo.',
|
|
268
|
+
},
|
|
269
|
+
branch: {
|
|
270
|
+
type: 'string',
|
|
271
|
+
description: 'create_session with provider "claude-cloud": the branch to check out (default: the repository\'s default branch).',
|
|
272
|
+
},
|
|
205
273
|
mode: {
|
|
206
274
|
type: 'string',
|
|
207
275
|
enum: [...REMOTE_RELAY_MODES],
|
|
@@ -247,13 +315,46 @@ function invalid(error) {
|
|
|
247
315
|
|
|
248
316
|
const SESSION_ACTIONS = new Set(['read_session', 'wait', 'send', 'stop', 'archive']);
|
|
249
317
|
|
|
318
|
+
/**
|
|
319
|
+
* The longest wait a relay may be set to allow, from whatever was stored or
|
|
320
|
+
* sent: a whole number of seconds inside the range, or the default.
|
|
321
|
+
*/
|
|
322
|
+
export function normalizeRemoteRelayMaxWaitSeconds(value) {
|
|
323
|
+
const number = Number(value);
|
|
324
|
+
if (value === null || value === undefined || value === '' || !Number.isFinite(number)) {
|
|
325
|
+
return REMOTE_RELAY_LIMITS.waitMaxSeconds;
|
|
326
|
+
}
|
|
327
|
+
return Math.min(
|
|
328
|
+
REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
|
|
329
|
+
Math.max(REMOTE_RELAY_LIMITS.waitMaxFloorSeconds, Math.trunc(number)),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The repository of a Claude Cloud session as an agent may write it: any form
|
|
335
|
+
* shared/claude-cloud/repo-url.mjs takes, or the bare `owner/repo`. Returns
|
|
336
|
+
* the canonical https URL, or '' when the text names no GitHub repository.
|
|
337
|
+
*/
|
|
338
|
+
export function normalizeRemoteRelayRepo(value) {
|
|
339
|
+
const text = toText(value);
|
|
340
|
+
if (!text) return '';
|
|
341
|
+
const direct = normalizeGitHubRepoUrl(text);
|
|
342
|
+
if (direct) return direct.repoUrl;
|
|
343
|
+
if (!/^[^/\s:@]+\/[^/\s:@]+$/.test(text)) return '';
|
|
344
|
+
return normalizeGitHubRepoUrl(`https://github.com/${text}`)?.repoUrl || '';
|
|
345
|
+
}
|
|
346
|
+
|
|
250
347
|
/**
|
|
251
348
|
* Validates and normalises one tool call. Adapters call it for a crisp early
|
|
252
349
|
* message; the relay calls it again authoritatively. Returns
|
|
253
350
|
* `{ ok:true, action, args }` with only the fields the action uses, defaults
|
|
254
351
|
* filled in and numbers clamped, or `{ ok:false, code, error }`.
|
|
352
|
+
*
|
|
353
|
+
* `maxWaitSeconds` is the relay's own setting; without it (an adapter, which
|
|
354
|
+
* does not know the setting) wait_seconds is clamped to the ceiling only.
|
|
355
|
+
* `waitLimitedFrom` is set on the result when the relay's setting cut the wait.
|
|
255
356
|
*/
|
|
256
|
-
export function validateRemoteRelayToolInput(input = {}) {
|
|
357
|
+
export function validateRemoteRelayToolInput(input = {}, { maxWaitSeconds } = {}) {
|
|
257
358
|
const action = toText(input?.action).toLowerCase();
|
|
258
359
|
if (!REMOTE_RELAY_ACTIONS.includes(action)) {
|
|
259
360
|
return invalid(`action must be one of: ${REMOTE_RELAY_ACTIONS.join(', ')}`);
|
|
@@ -271,12 +372,20 @@ export function validateRemoteRelayToolInput(input = {}) {
|
|
|
271
372
|
args.session = session;
|
|
272
373
|
}
|
|
273
374
|
|
|
274
|
-
const
|
|
375
|
+
const waitCeiling = clampInteger(input?.wait_seconds, {
|
|
275
376
|
min: 0,
|
|
276
|
-
max: REMOTE_RELAY_LIMITS.
|
|
377
|
+
max: REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds,
|
|
277
378
|
fallback: REMOTE_RELAY_LIMITS.waitDefaultSeconds,
|
|
278
379
|
});
|
|
279
|
-
if (
|
|
380
|
+
if (waitCeiling === null) return invalid('wait_seconds must be a number of seconds');
|
|
381
|
+
const waitMax = maxWaitSeconds === undefined
|
|
382
|
+
? REMOTE_RELAY_LIMITS.waitMaxCeilingSeconds
|
|
383
|
+
: normalizeRemoteRelayMaxWaitSeconds(maxWaitSeconds);
|
|
384
|
+
const waitSeconds = Math.min(waitCeiling, waitMax);
|
|
385
|
+
// What the agent asked for, when the relay's setting allows less.
|
|
386
|
+
const waitLimited = waitSeconds < waitCeiling
|
|
387
|
+
? { waitLimitedFrom: Math.trunc(Math.min(Number(input.wait_seconds), Number.MAX_SAFE_INTEGER)) }
|
|
388
|
+
: {};
|
|
280
389
|
|
|
281
390
|
const optionalEnum = (key, allowed) => {
|
|
282
391
|
const value = toText(input?.[key]).toLowerCase();
|
|
@@ -338,6 +447,29 @@ export function validateRemoteRelayToolInput(input = {}) {
|
|
|
338
447
|
if (!providerCheck.ok) return providerCheck;
|
|
339
448
|
optionalText('cwd', 1000);
|
|
340
449
|
optionalText('title', 200);
|
|
450
|
+
// A Claude Cloud session clones a repository instead of opening a
|
|
451
|
+
// folder: repo and branch belong to that provider alone. A call that
|
|
452
|
+
// names no provider gets this session's, so "repo is missing" for a
|
|
453
|
+
// cloud caller is the relay's to say.
|
|
454
|
+
const repoText = toText(input?.repo);
|
|
455
|
+
const branchText = toText(input?.branch);
|
|
456
|
+
if (args.provider === REMOTE_RELAY_CLOUD_PROVIDER) {
|
|
457
|
+
if (!repoText) {
|
|
458
|
+
return invalid('create_session with provider "claude-cloud" needs repo (https://github.com/owner/repo or owner/repo)');
|
|
459
|
+
}
|
|
460
|
+
if (args.cwd) return invalid('cwd does not apply to provider "claude-cloud": it clones repo instead of opening a folder');
|
|
461
|
+
} else if (repoText || branchText) {
|
|
462
|
+
return invalid('repo and branch go with provider "claude-cloud" only; pass provider "claude-cloud", or cwd for a folder on the relay');
|
|
463
|
+
}
|
|
464
|
+
if (repoText) {
|
|
465
|
+
const repo = normalizeRemoteRelayRepo(repoText);
|
|
466
|
+
if (!repo) return invalid('repo must be a GitHub repository: https://github.com/owner/repo or owner/repo');
|
|
467
|
+
args.repo = repo;
|
|
468
|
+
}
|
|
469
|
+
if (branchText) {
|
|
470
|
+
if (!isValidBranchName(branchText)) return invalid(`branch "${branchText.slice(0, 80)}" is not a valid branch name`);
|
|
471
|
+
args.branch = branchText;
|
|
472
|
+
}
|
|
341
473
|
}
|
|
342
474
|
} else if (action === 'answer_question') {
|
|
343
475
|
const questionId = toText(input?.question_id);
|
|
@@ -351,7 +483,8 @@ export function validateRemoteRelayToolInput(input = {}) {
|
|
|
351
483
|
if (answer) args.answer = answer.slice(0, REMOTE_RELAY_LIMITS.textMax);
|
|
352
484
|
if (choices.length) args.choices = choices;
|
|
353
485
|
}
|
|
354
|
-
|
|
486
|
+
const waits = Object.prototype.hasOwnProperty.call(args, 'wait_seconds');
|
|
487
|
+
return { ok: true, action, args, ...(waits ? waitLimited : {}) };
|
|
355
488
|
}
|
|
356
489
|
|
|
357
490
|
const PERMISSION_RANK = { read: 1, prompt: 2, full: 3 };
|
|
@@ -373,6 +506,10 @@ export function isRemoteRelayWriteAction(action) {
|
|
|
373
506
|
return REMOTE_RELAY_WRITE_ACTIONS.includes(action);
|
|
374
507
|
}
|
|
375
508
|
|
|
509
|
+
export function isRemoteRelayPromptAction(action) {
|
|
510
|
+
return REMOTE_RELAY_PROMPT_ACTIONS.includes(action);
|
|
511
|
+
}
|
|
512
|
+
|
|
376
513
|
// ─── Approval cards ──────────────────────────────────────────────────────────
|
|
377
514
|
|
|
378
515
|
// The Allow / Deny card a relay puts on its own turn before a write action on
|
|
@@ -403,7 +540,12 @@ const ORIGIN_CAPS = {
|
|
|
403
540
|
/**
|
|
404
541
|
* Sanitises an `origin` object received on /api/message or bootstrap (or built
|
|
405
542
|
* locally before forwarding). Returns null when it does not describe an agent
|
|
406
|
-
* on
|
|
543
|
+
* on a relay. Every field is a capped string except `hops` and `local`.
|
|
544
|
+
*
|
|
545
|
+
* `local: true` marks an agent of THIS relay (the local target): the
|
|
546
|
+
* conversation named by `conversationId` lives here. Only the in-process
|
|
547
|
+
* loopback may claim it; the inbound side drops it from anything that came in
|
|
548
|
+
* over HTTP (readRemoteRelayRequest).
|
|
407
549
|
*/
|
|
408
550
|
export function normalizeRemoteRelayOrigin(raw) {
|
|
409
551
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
@@ -416,6 +558,7 @@ export function normalizeRemoteRelayOrigin(raw) {
|
|
|
416
558
|
if (origin.relayUrl && !/^https?:\/\//i.test(origin.relayUrl)) origin.relayUrl = '';
|
|
417
559
|
const hops = Number(raw.hops);
|
|
418
560
|
origin.hops = Number.isInteger(hops) && hops >= 0 ? Math.min(hops, 10) : 1;
|
|
561
|
+
if (raw.local === true) origin.local = true;
|
|
419
562
|
return origin;
|
|
420
563
|
}
|
|
421
564
|
|
|
@@ -429,6 +572,8 @@ function quoteForHeader(value) {
|
|
|
429
572
|
* message carries `origin` (the badge shows the same facts).
|
|
430
573
|
*/
|
|
431
574
|
export function formatRemotePromptHeader(origin) {
|
|
575
|
+
// The same line for an agent of this relay (origin.local): REMOTE_PROMPT_HEADER_PATTERN
|
|
576
|
+
// and every reader that strips it stay as they are.
|
|
432
577
|
const parts = [`[Remote prompt from an agent on relay "${quoteForHeader(origin?.relayName) || 'unknown'}"`];
|
|
433
578
|
const title = quoteForHeader(origin?.conversationTitle);
|
|
434
579
|
if (title) parts.push(`session "${title}"`);
|