@oar-sh/oar 0.9.17 → 0.9.18
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/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +192 -0
- package/README.md +13 -12
- package/package.json +1 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
- package/server/claude-worker/claude-session-process.mjs +166 -21
- package/server/claude-worker/claude-session-worker-main.mjs +3 -0
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-publisher.mjs +117 -5
- package/server/claude-worker/claude-usage-totals.mjs +53 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +33 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- package/server/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +52 -0
- package/server/public/app/bootstrap.js +153 -27
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +6 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +11 -0
- package/server/public/app/conversation-view.js +29 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +127 -10
- package/server/public/app/model-selector-options.mjs +17 -1
- package/server/public/app/new-conversation-model-choice.mjs +21 -0
- package/server/public/app/provider-readiness.mjs +5 -2
- package/server/public/app/relay-error-ctas.mjs +50 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +189 -3
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +1 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +1 -1
- package/server/routes/messages-routes.mjs +645 -27
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +327 -29
- package/server/server-runtime.mjs +363 -26
- package/server/services/about-service.mjs +1 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -0
- package/server/services/plan-usage-claude-api.mjs +445 -0
- package/server/services/plan-usage-service.mjs +5 -0
- package/server/services/provider-model-selection.mjs +3 -2
- package/server/services/provider-reasoning-effort.mjs +9 -0
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +83 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +61 -2
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +2 -0
- package/shared/anthropic-api.mjs +202 -0
- package/shared/provider-routing.mjs +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// A small client for the one Anthropic endpoint the relay calls itself with a
|
|
2
|
+
// Claude API key: `GET /v1/models`, the model discovery of the Claude API
|
|
3
|
+
// provider (Settings → Providers → Claude API). Turns do not go through here:
|
|
4
|
+
// the session worker runs Claude Code on the key.
|
|
5
|
+
//
|
|
6
|
+
// The key goes to the Anthropic API and nowhere else. The only other target
|
|
7
|
+
// is a fake API on the loopback interface, named by OAR_ANTHROPIC_API_BASE_URL
|
|
8
|
+
// for tests; any other value of that variable is ignored (the rule of
|
|
9
|
+
// shared/claude-cloud/base-url.mjs, which this module reuses). There is no
|
|
10
|
+
// base-URL setting and no parameter that takes one.
|
|
11
|
+
//
|
|
12
|
+
// The key is in no error this module produces: a response body is quoted only
|
|
13
|
+
// after the key has been taken out of it.
|
|
14
|
+
|
|
15
|
+
import { loopbackBaseUrlOrNull } from './claude-cloud/base-url.mjs';
|
|
16
|
+
|
|
17
|
+
export const ANTHROPIC_API_BASE_URL = 'https://api.anthropic.com';
|
|
18
|
+
export const ANTHROPIC_API_BASE_URL_ENV = 'OAR_ANTHROPIC_API_BASE_URL';
|
|
19
|
+
export const ANTHROPIC_API_VERSION = '2023-06-01';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
22
|
+
// The largest page `GET /v1/models` serves.
|
|
23
|
+
const MODELS_PAGE_LIMIT = 1000;
|
|
24
|
+
// One page holds every model today; the cap keeps a cursor that never ends
|
|
25
|
+
// from turning into an endless walk.
|
|
26
|
+
const MAX_MODEL_PAGES = 20;
|
|
27
|
+
const MAX_DETAIL_CHARS = 240;
|
|
28
|
+
// The effort levels the Models API reports per model, in ladder order.
|
|
29
|
+
const EFFORT_LEVELS = Object.freeze(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The base URL for the client: the loopback URL named by
|
|
33
|
+
* OAR_ANTHROPIC_API_BASE_URL, else the Anthropic API.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveAnthropicApiBaseUrl(env = process.env) {
|
|
36
|
+
return loopbackBaseUrlOrNull(env?.[ANTHROPIC_API_BASE_URL_ENV]) || ANTHROPIC_API_BASE_URL;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class AnthropicApiError extends Error {
|
|
40
|
+
constructor(message, { code = 'error', status = null } = {}) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'AnthropicApiError';
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.status = status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function codeForStatus(status) {
|
|
49
|
+
if (status === 401) return 'unauthorized';
|
|
50
|
+
if (status === 403) return 'forbidden';
|
|
51
|
+
if (status === 402) return 'billing';
|
|
52
|
+
if (status === 429) return 'rate_limited';
|
|
53
|
+
if (status >= 500) return 'transient';
|
|
54
|
+
return 'error';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function redact(text, apiKey) {
|
|
58
|
+
const value = String(text ?? '');
|
|
59
|
+
const key = String(apiKey ?? '').trim();
|
|
60
|
+
return key ? value.split(key).join('[redacted]') : value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toText(value) {
|
|
64
|
+
return String(value ?? '').trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function positiveInteger(value) {
|
|
68
|
+
const number = Number(value);
|
|
69
|
+
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A capability as the API writes it (`{ supported: true }`) or as a plain
|
|
74
|
+
* boolean; null when it is missing or of another shape.
|
|
75
|
+
*/
|
|
76
|
+
function capabilitySupported(value) {
|
|
77
|
+
if (typeof value === 'boolean') return value;
|
|
78
|
+
if (value && typeof value === 'object' && typeof value.supported === 'boolean') return value.supported;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `{ type: 'error', error: { type, message } }` as one line, or the raw text. */
|
|
83
|
+
function errorDetail(text) {
|
|
84
|
+
const raw = toText(text);
|
|
85
|
+
if (!raw) return '';
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(raw);
|
|
88
|
+
const type = toText(parsed?.error?.type);
|
|
89
|
+
const message = toText(parsed?.error?.message);
|
|
90
|
+
if (type || message) return [type, message].filter(Boolean).join(': ');
|
|
91
|
+
} catch {
|
|
92
|
+
// Not JSON: the text as it came.
|
|
93
|
+
}
|
|
94
|
+
return raw;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One entry of `GET /v1/models` in OAR's terms, or null without an id:
|
|
99
|
+
* `{ id, displayName, contextWindow, maxOutput, imageInput, effort,
|
|
100
|
+
* effortLevels, thinking, thinkingTypes }`. Every field but the id may be
|
|
101
|
+
* missing in an answer, so each one is null (or an empty list) then.
|
|
102
|
+
*/
|
|
103
|
+
export function mapAnthropicModel(entry) {
|
|
104
|
+
const id = toText(entry?.id);
|
|
105
|
+
if (!id) return null;
|
|
106
|
+
const capabilities = entry?.capabilities && typeof entry.capabilities === 'object' ? entry.capabilities : {};
|
|
107
|
+
const effort = capabilities.effort;
|
|
108
|
+
const effortObject = effort && typeof effort === 'object' ? effort : {};
|
|
109
|
+
const thinking = capabilities.thinking;
|
|
110
|
+
const thinkingTypesObject = thinking && typeof thinking === 'object' && thinking.types && typeof thinking.types === 'object'
|
|
111
|
+
? thinking.types
|
|
112
|
+
: {};
|
|
113
|
+
return {
|
|
114
|
+
id,
|
|
115
|
+
displayName: toText(entry?.display_name) || id,
|
|
116
|
+
contextWindow: positiveInteger(entry?.max_input_tokens),
|
|
117
|
+
maxOutput: positiveInteger(entry?.max_tokens),
|
|
118
|
+
imageInput: capabilitySupported(capabilities.image_input),
|
|
119
|
+
effort: capabilitySupported(effort),
|
|
120
|
+
effortLevels: EFFORT_LEVELS.filter((level) => capabilitySupported(effortObject[level]) === true),
|
|
121
|
+
thinking: capabilitySupported(thinking),
|
|
122
|
+
thinkingTypes: Object.keys(thinkingTypesObject)
|
|
123
|
+
.filter((type) => capabilitySupported(thinkingTypesObject[type]) === true)
|
|
124
|
+
.map((type) => type.toLowerCase()),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createAnthropicApiClient({
|
|
129
|
+
apiKey = '',
|
|
130
|
+
env = process.env,
|
|
131
|
+
fetchImpl = globalThis.fetch,
|
|
132
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
133
|
+
} = {}) {
|
|
134
|
+
const baseUrl = resolveAnthropicApiBaseUrl(env);
|
|
135
|
+
const key = toText(apiKey);
|
|
136
|
+
|
|
137
|
+
async function getJson(pathname, what) {
|
|
138
|
+
if (!key) throw new AnthropicApiError('Claude API key is not configured', { code: 'not_configured' });
|
|
139
|
+
let response;
|
|
140
|
+
try {
|
|
141
|
+
response = await fetchImpl(`${baseUrl}${pathname}`, {
|
|
142
|
+
headers: {
|
|
143
|
+
'x-api-key': key,
|
|
144
|
+
'anthropic-version': ANTHROPIC_API_VERSION,
|
|
145
|
+
accept: 'application/json',
|
|
146
|
+
},
|
|
147
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
|
151
|
+
throw new AnthropicApiError(
|
|
152
|
+
timedOut
|
|
153
|
+
? `Anthropic ${what} timed out after ${timeoutMs} ms`
|
|
154
|
+
: `Anthropic ${what} failed: ${redact(error?.message || error, key)}`,
|
|
155
|
+
{ code: timedOut ? 'timeout' : 'network' },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const detail = redact(errorDetail(await response.text().catch(() => '')), key).slice(0, MAX_DETAIL_CHARS);
|
|
160
|
+
throw new AnthropicApiError(
|
|
161
|
+
`Anthropic ${what} failed (${response.status})${detail ? `: ${detail}` : ''}`,
|
|
162
|
+
{ code: codeForStatus(response.status), status: response.status },
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return await response.json();
|
|
167
|
+
} catch {
|
|
168
|
+
throw new AnthropicApiError(`Anthropic ${what} returned invalid JSON`, { code: 'invalid_response' });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
baseUrl,
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Every model the key may use (mapAnthropicModel), in the API's order,
|
|
177
|
+
* without duplicates. Follows `has_more` / `last_id` page by page.
|
|
178
|
+
*/
|
|
179
|
+
async listModels() {
|
|
180
|
+
const models = [];
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
const cursors = new Set();
|
|
183
|
+
let afterId = '';
|
|
184
|
+
for (let page = 0; page < MAX_MODEL_PAGES; page += 1) {
|
|
185
|
+
const query = new URLSearchParams({ limit: String(MODELS_PAGE_LIMIT) });
|
|
186
|
+
if (afterId) query.set('after_id', afterId);
|
|
187
|
+
const payload = await getJson(`/v1/models?${query.toString()}`, 'model discovery');
|
|
188
|
+
for (const entry of Array.isArray(payload?.data) ? payload.data : []) {
|
|
189
|
+
const model = mapAnthropicModel(entry);
|
|
190
|
+
if (!model || seen.has(model.id)) continue;
|
|
191
|
+
seen.add(model.id);
|
|
192
|
+
models.push(model);
|
|
193
|
+
}
|
|
194
|
+
const lastId = toText(payload?.last_id);
|
|
195
|
+
if (payload?.has_more !== true || !lastId || cursors.has(lastId)) break;
|
|
196
|
+
cursors.add(lastId);
|
|
197
|
+
afterId = lastId;
|
|
198
|
+
}
|
|
199
|
+
return models;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -12,8 +12,10 @@
|
|
|
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
|
-
|
|
15
|
+
// drives a session in Anthropic's cloud and spawns no CLI. `claude-api` is
|
|
16
|
+
// its own too: the Claude worker on a Console API key instead of the host's
|
|
17
|
+
// subscription login.
|
|
18
|
+
export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud', 'deepseek', 'claude-api']);
|
|
17
19
|
|
|
18
20
|
export function isLegacyRelayProviderType(value) {
|
|
19
21
|
const normalized = String(value || '').trim().toLowerCase();
|
|
@@ -25,7 +27,7 @@ export function isSessionWorkerProviderType(value) {
|
|
|
25
27
|
return SESSION_WORKER_PROVIDER_TYPES.includes(String(value || '').trim().toLowerCase());
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
/** `'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek'` — for interpolation into SQL NOT IN (...) lists. */
|
|
30
|
+
/** `'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek', 'claude-api'` — for interpolation into SQL NOT IN (...) lists. */
|
|
29
31
|
export function sessionWorkerProviderSqlList() {
|
|
30
32
|
return SESSION_WORKER_PROVIDER_TYPES.map((provider) => `'${provider}'`).join(', ');
|
|
31
33
|
}
|
package/shared/relay-asks.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Questions that don't block:
|
|
1
|
+
// Questions that don't block: relay_tool's `ask` puts a question for the
|
|
2
2
|
// owner into a session (a card there and in the Inbox) and returns at once;
|
|
3
3
|
// the asking session keeps working and is told the answer later, as a relay
|
|
4
4
|
// event (question_answered).
|
|
@@ -11,14 +11,20 @@
|
|
|
11
11
|
// Every text of an ask is written by an agent, possibly on another relay:
|
|
12
12
|
// whoever shows it shows it as plain text.
|
|
13
13
|
//
|
|
14
|
-
// SDK-free and side-effect free: imported by the server, the
|
|
14
|
+
// SDK-free and side-effect free: imported by the server, the relay_tool
|
|
15
15
|
// contract and the browser.
|
|
16
16
|
|
|
17
17
|
// The kind a question card carries in /api/relay-questions and the Inbox:
|
|
18
18
|
// `ask` (this feature), `approval` (a relay's Allow / Deny card on its own
|
|
19
|
-
// agent's call, context.source
|
|
20
|
-
// turn waits on).
|
|
19
|
+
// agent's call, context.source REMOTE_RELAY_APPROVAL_SOURCE), `ask_user`
|
|
20
|
+
// (every other card a turn waits on).
|
|
21
21
|
export const RELAY_QUESTION_KINDS = Object.freeze(['ask', 'ask_user', 'approval']);
|
|
22
|
+
|
|
23
|
+
// The approval card's `context.source`, re-exported by the contract, which
|
|
24
|
+
// imports this module (defined here so neither import goes in a circle).
|
|
25
|
+
// Stored and sent between relays, so it kept the tool's name from before
|
|
26
|
+
// 0.9.18.
|
|
27
|
+
export const REMOTE_RELAY_APPROVAL_SOURCE = 'remote_relay';
|
|
22
28
|
export const RELAY_ASK_KIND = 'ask';
|
|
23
29
|
|
|
24
30
|
export const RELAY_ASK_URGENCIES = Object.freeze(['normal', 'now']);
|
|
@@ -141,5 +147,5 @@ export function relayQuestionKind(question) {
|
|
|
141
147
|
const kind = toText(question?.kind);
|
|
142
148
|
if (RELAY_QUESTION_KINDS.includes(kind)) return kind;
|
|
143
149
|
const context = question && typeof question.context === 'object' && question.context ? question.context : {};
|
|
144
|
-
return toText(context.source) ===
|
|
150
|
+
return toText(context.source) === REMOTE_RELAY_APPROVAL_SOURCE ? 'approval' : 'ask_user';
|
|
145
151
|
}
|
package/shared/relay-events.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// then start with the relay's name). `replayed: true` marks an event a paired
|
|
9
9
|
// relay kept while the link to it was down.
|
|
10
10
|
//
|
|
11
|
-
// SDK-free and side-effect free: imported by the server, the
|
|
11
|
+
// SDK-free and side-effect free: imported by the server, the relay_tool
|
|
12
12
|
// contract and the browser (which renders the event rows).
|
|
13
13
|
|
|
14
14
|
import { RELAY_SCHEDULE_MESSAGE_KIND } from './relay-schedules.mjs';
|
|
@@ -22,7 +22,7 @@ export const RELAY_EVENT_MESSAGE_KIND = 'relay-event';
|
|
|
22
22
|
|
|
23
23
|
export const RELAY_EVENT_TYPES = Object.freeze([
|
|
24
24
|
'question_asked',
|
|
25
|
-
// The owner answered or dismissed a question that does not block (
|
|
25
|
+
// The owner answered or dismissed a question that does not block (relay_tool
|
|
26
26
|
// `ask`). The asking session is always told, without a subscription; a
|
|
27
27
|
// subscription that takes question_asked takes it too.
|
|
28
28
|
'question_answered',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Relay schedules: a prompt the relay queues into a session at a set time,
|
|
2
|
-
// once or every N minutes. An agent sets one with
|
|
2
|
+
// once or every N minutes. An agent sets one with relay_tool's `schedule`
|
|
3
3
|
// (its own conversation by default, or another session of this relay); the
|
|
4
4
|
// relay keeps it in its database, so it outlives a restart that the agent's
|
|
5
5
|
// own timers would not.
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// = 'relay-schedule'`) whose first line says what it is: automated, never the
|
|
9
9
|
// user's, and it approves nothing.
|
|
10
10
|
//
|
|
11
|
-
// SDK-free and side-effect free: imported by the server and the
|
|
11
|
+
// SDK-free and side-effect free: imported by the server and the relay_tool
|
|
12
12
|
// contract (the browser keeps its own copy of what it needs:
|
|
13
13
|
// server/public/app/relay-schedule-view.mjs).
|
|
14
14
|
|
|
@@ -29,9 +29,20 @@ import {
|
|
|
29
29
|
normalizeRelayScheduleAt,
|
|
30
30
|
normalizeRelayScheduleTitle,
|
|
31
31
|
} from './relay-schedules.mjs';
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
import {
|
|
33
|
+
RELAY_ASK_LIMITS,
|
|
34
|
+
RELAY_ASK_URGENCIES,
|
|
35
|
+
REMOTE_RELAY_APPROVAL_SOURCE,
|
|
36
|
+
normalizeRelayAskInput,
|
|
37
|
+
} from './relay-asks.mjs';
|
|
38
|
+
|
|
39
|
+
// The one name every provider lists the tool under. Up to 0.9.17 it was
|
|
40
|
+
// `remote_relay`; that name is still accepted where a runtime dispatches a
|
|
41
|
+
// call by name without having listed it (the OAR MCP server's tools/call),
|
|
42
|
+
// and recognised in transcripts and holds, but never listed: a second
|
|
43
|
+
// definition would cost every turn its context.
|
|
44
|
+
export const REMOTE_RELAY_TOOL_NAME = 'relay_tool';
|
|
45
|
+
export const REMOTE_RELAY_LEGACY_TOOL_NAME = 'remote_relay';
|
|
35
46
|
|
|
36
47
|
// Bumped when a relay starts relying on a newer inbound behaviour of its peers.
|
|
37
48
|
// 0 = a relay without this feature (reachable read/prompt only, no pairing).
|
|
@@ -154,8 +165,11 @@ export const REMOTE_RELAY_PROMPT_ACTIONS = Object.freeze(['send', 'create_sessio
|
|
|
154
165
|
export const REMOTE_RELAY_APPROVAL_MODES = Object.freeze(['ask', 'plan']);
|
|
155
166
|
|
|
156
167
|
export const REMOTE_RELAY_SESSION_SCOPES = Object.freeze(['active', 'recent', 'all']);
|
|
157
|
-
export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek']);
|
|
168
|
+
export const REMOTE_RELAY_PROVIDERS = Object.freeze(['github', 'openai', 'claude', 'cursor', 'grok', 'claude-cloud', 'deepseek', 'claude-api']);
|
|
158
169
|
export const REMOTE_RELAY_MODES = Object.freeze(['plan', 'ask', 'agent', 'autopilot']);
|
|
170
|
+
// What `send` may name as `provider`: a Claude session's billing, the
|
|
171
|
+
// subscription or the API key. Naming the other one switches the session.
|
|
172
|
+
export const REMOTE_RELAY_SEND_PROVIDERS = Object.freeze(['claude', 'claude-api']);
|
|
159
173
|
export const REMOTE_RELAY_IF_BUSY = Object.freeze(['queue', 'fail']);
|
|
160
174
|
// Reasoning efforts differ per provider and model (none, low, medium, high,
|
|
161
175
|
// xhigh, max, ultracode, ...), so the contract only checks the shape of the
|
|
@@ -447,13 +461,15 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
|
|
|
447
461
|
provider: {
|
|
448
462
|
type: 'string',
|
|
449
463
|
enum: [...REMOTE_RELAY_PROVIDERS],
|
|
450
|
-
description: 'create_session: provider (default: the same provider as this session).'
|
|
464
|
+
description: 'create_session: provider (default: the same provider as this session). '
|
|
465
|
+
+ 'send to a Claude session: "claude" (the subscription) or "claude-api" (the API key) switches its billing from the next turn, '
|
|
466
|
+
+ 'together with this message; the other billing must be set up there. Omit it to keep the session\'s billing.',
|
|
451
467
|
},
|
|
452
468
|
model: {
|
|
453
469
|
type: 'string',
|
|
454
470
|
description: 'create_session: model id (default: this session\'s model when the remote offers it, else the remote\'s default). '
|
|
455
471
|
+ 'send: switch the remote session to this model (default: it keeps its own). update_session: the session\'s model from now on. '
|
|
456
|
-
+ 'Must be an id relay_info lists for the provider (Claude also
|
|
472
|
+
+ 'Must be an id relay_info lists for the provider (Claude and Claude API also take "opus", "sonnet", "haiku"): '
|
|
457
473
|
+ 'an unknown id is refused with the close matches, nothing created or sent.',
|
|
458
474
|
},
|
|
459
475
|
cwd: {
|
|
@@ -660,7 +676,7 @@ export const REMOTE_RELAY_ACTION_ARGUMENTS = Object.freeze({
|
|
|
660
676
|
read_session: Object.freeze(['session', 'last', 'before', 'include_activity', 'max_chars']),
|
|
661
677
|
read_task_output: Object.freeze(['session', 'task_id', 'lines']),
|
|
662
678
|
wait: Object.freeze(['session', 'message_id', 'wait_seconds']),
|
|
663
|
-
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy']),
|
|
679
|
+
send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy', 'provider']),
|
|
664
680
|
create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds']),
|
|
665
681
|
update_session: Object.freeze(['session', ...REMOTE_RELAY_SESSION_SETTINGS]),
|
|
666
682
|
answer_question: Object.freeze(['question_id', 'answer', 'choices']),
|
|
@@ -867,6 +883,10 @@ function validateToolInput(input, { maxWaitSeconds, now } = {}) {
|
|
|
867
883
|
const busyCheck = optionalEnum('if_busy', REMOTE_RELAY_IF_BUSY);
|
|
868
884
|
if (!busyCheck.ok) return busyCheck;
|
|
869
885
|
args.if_busy = args.if_busy || 'queue';
|
|
886
|
+
const providerCheck = optionalEnum('provider', REMOTE_RELAY_SEND_PROVIDERS);
|
|
887
|
+
if (!providerCheck.ok) {
|
|
888
|
+
return invalid('send takes provider "claude" (the subscription) or "claude-api" (the API key), for a Claude session only');
|
|
889
|
+
}
|
|
870
890
|
} else {
|
|
871
891
|
const providerCheck = optionalEnum('provider', REMOTE_RELAY_PROVIDERS);
|
|
872
892
|
if (!providerCheck.ok) return providerCheck;
|
|
@@ -1222,8 +1242,8 @@ export function formatSessionGroupList(groups, max = REMOTE_RELAY_GROUP_LIMITS.r
|
|
|
1222
1242
|
// another relay (ask / plan mode) is an ordinary relay question whose
|
|
1223
1243
|
// `context.source` is this. Only that relay's user may answer it: its question
|
|
1224
1244
|
// routes hide the card from other relays' agents and refuse their answers, and
|
|
1225
|
-
// a dispatcher never offers one to its agent.
|
|
1226
|
-
export
|
|
1245
|
+
// a dispatcher never offers one to its agent. Defined in relay-asks.mjs.
|
|
1246
|
+
export { REMOTE_RELAY_APPROVAL_SOURCE };
|
|
1227
1247
|
|
|
1228
1248
|
/** True for such a card: a formatted question, or a stored request envelope (both carry `context`). */
|
|
1229
1249
|
export function isRemoteRelayApprovalQuestion(question) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The provider-facing side of the `
|
|
1
|
+
// The provider-facing side of the `relay_tool` tool, in one place.
|
|
2
2
|
//
|
|
3
3
|
// Every adapter (Claude's in-process MCP server, Cursor's customTools, the
|
|
4
4
|
// Copilot SDK engine's session tools, and the OAR stdio MCP server that Grok
|
|
@@ -16,12 +16,14 @@ import {
|
|
|
16
16
|
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
17
17
|
REMOTE_RELAY_TOOL_ENDPOINT,
|
|
18
18
|
REMOTE_RELAY_TOOL_INPUT_SCHEMA,
|
|
19
|
+
REMOTE_RELAY_LEGACY_TOOL_NAME,
|
|
19
20
|
REMOTE_RELAY_TOOL_NAME,
|
|
20
21
|
summarizeRemoteRelayCall,
|
|
21
22
|
validateRemoteRelayToolInput,
|
|
22
23
|
} from './remote-relay-contract.mjs';
|
|
23
24
|
|
|
24
25
|
export {
|
|
26
|
+
REMOTE_RELAY_LEGACY_TOOL_NAME,
|
|
25
27
|
REMOTE_RELAY_TOOL_DESCRIPTION,
|
|
26
28
|
REMOTE_RELAY_TOOL_ENDPOINT,
|
|
27
29
|
REMOTE_RELAY_TOOL_INPUT_SCHEMA,
|
|
@@ -141,7 +143,7 @@ export function remoteRelayCancelledResult() {
|
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
/**
|
|
144
|
-
* Executes one `
|
|
146
|
+
* Executes one `relay_tool` call: validate, then forward
|
|
145
147
|
* `{ conversationId, action, args }` to the local relay and hand back its JSON.
|
|
146
148
|
* Never throws — a refusal reads as an answer the model can act on, the same
|
|
147
149
|
* contract as executePreviewTool.
|
|
@@ -256,7 +258,7 @@ export function createRemoteRelayToolGate({
|
|
|
256
258
|
}
|
|
257
259
|
|
|
258
260
|
/**
|
|
259
|
-
* How many
|
|
261
|
+
* How many relay_tool calls of this conversation the relay is still working
|
|
260
262
|
* on. 0 on any failure: the caller's watchdog then does what it would have
|
|
261
263
|
* done without the question.
|
|
262
264
|
*/
|
|
@@ -292,14 +294,29 @@ export function formatRemoteRelayToolResult(result) {
|
|
|
292
294
|
// ─── Activity lines ──────────────────────────────────────────────────────────
|
|
293
295
|
|
|
294
296
|
// A provider may report the tool under its bare name or namespaced by the MCP
|
|
295
|
-
// server that carries it (`
|
|
296
|
-
// `oar/
|
|
297
|
-
|
|
297
|
+
// server that carries it (`mcp__relay__relay_tool`, `oar__relay_tool`,
|
|
298
|
+
// `oar/relay_tool`, `oar-relay_tool`); all of them are this tool. So is the
|
|
299
|
+
// name it had before, `remote_relay`, in every spelling: transcripts and
|
|
300
|
+
// sessions from before the rename still render and hold the same way.
|
|
301
|
+
const REMOTE_RELAY_NAME_PATTERN = new RegExp(
|
|
302
|
+
`(?:^|__|[^A-Za-z0-9_])(?:${REMOTE_RELAY_TOOL_NAME}|${REMOTE_RELAY_LEGACY_TOOL_NAME})$`,
|
|
303
|
+
'i',
|
|
304
|
+
);
|
|
298
305
|
|
|
299
306
|
export function isRemoteRelayToolName(name) {
|
|
300
307
|
return REMOTE_RELAY_NAME_PATTERN.test(toText(name));
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The tool's listed name for a bare call name that means it: `relay_tool`
|
|
312
|
+
* itself, or the legacy `remote_relay` a dispatcher still accepts without
|
|
313
|
+
* listing it. Any other name comes back unchanged.
|
|
314
|
+
*/
|
|
315
|
+
export function canonicalRemoteRelayToolCallName(name) {
|
|
316
|
+
const text = toText(name);
|
|
317
|
+
return text === REMOTE_RELAY_LEGACY_TOOL_NAME ? REMOTE_RELAY_TOOL_NAME : text;
|
|
318
|
+
}
|
|
319
|
+
|
|
303
320
|
/**
|
|
304
321
|
* The arguments of a tool call as an object, whether the provider delivered
|
|
305
322
|
* them parsed or as a JSON string. Null when there is nothing usable.
|
|
@@ -86,6 +86,7 @@ export function steerAgentLabelForProvider(providerType) {
|
|
|
86
86
|
case 'grok': return 'Grok';
|
|
87
87
|
case 'claude-cloud': return 'Claude Cloud';
|
|
88
88
|
case 'deepseek': return 'DeepSeek';
|
|
89
|
+
case 'claude-api': return 'Claude API';
|
|
89
90
|
default: return DEFAULT_STEER_AGENT_LABEL;
|
|
90
91
|
}
|
|
91
92
|
}
|
|
@@ -19,7 +19,7 @@ function httpError(status, routePath, text) {
|
|
|
19
19
|
error.status = status;
|
|
20
20
|
error.detail = detail;
|
|
21
21
|
// The parsed error body, for callers that pass the relay's answer on as
|
|
22
|
-
// is (a refused
|
|
22
|
+
// is (a refused relay_tool call carries its code there).
|
|
23
23
|
error.body = payload && typeof payload === "object" ? payload : null;
|
|
24
24
|
return error;
|
|
25
25
|
}
|
|
@@ -89,7 +89,7 @@ function requestWithoutTimeout({ url, method, headers, payload, signal }) {
|
|
|
89
89
|
export function createApiClient({ serverUrl, token, getHeaders }) {
|
|
90
90
|
/**
|
|
91
91
|
* `options.longCall`: a call the relay may legitimately hold open for many
|
|
92
|
-
* minutes (the
|
|
92
|
+
* minutes (the relay_tool) — it goes over node:http without any
|
|
93
93
|
* timeout instead of fetch. `options.signal` aborts either kind; an aborted
|
|
94
94
|
* call rejects with an AbortError.
|
|
95
95
|
*/
|
|
@@ -33,6 +33,10 @@ export function createHeartbeatController({
|
|
|
33
33
|
// Optional: called after every beat the relay answered, for work that
|
|
34
34
|
// waits for a reachable relay (posts kept after a failure).
|
|
35
35
|
onAnswered = () => {},
|
|
36
|
+
// Optional: the kind of worker this is ("claude", "claude-api", ...). The
|
|
37
|
+
// relay hands a Claude chat's row only to a worker of the row's billing,
|
|
38
|
+
// and knows a worker's kind from what it says here and in its hello.
|
|
39
|
+
workerKind = null,
|
|
36
40
|
// requestPulse's window: one turn transition can flip the steering
|
|
37
41
|
// snapshot several times in quick succession, and each flip only needs to
|
|
38
42
|
// reach the relay once.
|
|
@@ -66,6 +70,7 @@ export function createHeartbeatController({
|
|
|
66
70
|
...(steering && typeof steering === "object" ? { steering } : {}),
|
|
67
71
|
...(settleFailed.length ? { settleFailed } : {}),
|
|
68
72
|
...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
|
|
73
|
+
...(workerKind ? { workerKind: String(workerKind) } : {}),
|
|
69
74
|
};
|
|
70
75
|
const response = await api("POST", "/api/heartbeat", body);
|
|
71
76
|
// Failed by the relay, or skipped because this worker has no claim on
|
|
@@ -66,6 +66,10 @@ export function createWorkerWebSocketLink({
|
|
|
66
66
|
onServerHello = () => {},
|
|
67
67
|
getSessionId = () => null,
|
|
68
68
|
getPid = () => null,
|
|
69
|
+
// The kind of worker this is ("claude", "claude-api", ...), stated in
|
|
70
|
+
// every hello and ready: the relay hands a Claude chat's row only to a
|
|
71
|
+
// worker of the row's billing.
|
|
72
|
+
workerKind = null,
|
|
69
73
|
minBackoffMs = 1000,
|
|
70
74
|
maxBackoffMs = 8_000,
|
|
71
75
|
jitterMs = 250,
|
|
@@ -170,6 +174,7 @@ export function createWorkerWebSocketLink({
|
|
|
170
174
|
reason,
|
|
171
175
|
sessionId: normalizeText(getSessionId()),
|
|
172
176
|
pid: normalizePositiveInt(getPid()),
|
|
177
|
+
...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
|
|
173
178
|
...(held ? { ready: false } : {}),
|
|
174
179
|
});
|
|
175
180
|
if (sent) lastHelloSentAt = getNowMs();
|
|
@@ -197,6 +202,7 @@ export function createWorkerWebSocketLink({
|
|
|
197
202
|
reason,
|
|
198
203
|
sessionId: normalizeText(getSessionId()),
|
|
199
204
|
pid: normalizePositiveInt(getPid()),
|
|
205
|
+
...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
|
|
200
206
|
});
|
|
201
207
|
if (sent) lastReadySentAt = getNowMs();
|
|
202
208
|
return sent;
|