@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.
Files changed (101) hide show
  1. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  2. package/CHANGELOG.md +192 -0
  3. package/README.md +13 -12
  4. package/package.json +1 -1
  5. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  6. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  7. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  9. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  10. package/server/claude-worker/claude-session-process.mjs +166 -21
  11. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  12. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  13. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  14. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  15. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  16. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  19. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  20. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  21. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  22. package/server/db-schema.mjs +33 -1
  23. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  24. package/server/grok-worker/acp-client.mjs +1 -1
  25. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  26. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  27. package/server/mcp/oar-mcp-server.mjs +11 -5
  28. package/server/migrations/0031-queue-provider.mjs +143 -0
  29. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  30. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  31. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  32. package/server/public/app/activity-replay-state.mjs +4 -3
  33. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  34. package/server/public/app/agent-sessions-settings.mjs +1 -1
  35. package/server/public/app/api-client.js +52 -0
  36. package/server/public/app/bootstrap.js +153 -27
  37. package/server/public/app/claude-api-settings-ui.js +362 -0
  38. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  39. package/server/public/app/claude-billing-toggle.mjs +112 -0
  40. package/server/public/app/context-tier-options.mjs +6 -2
  41. package/server/public/app/context-usage-view.mjs +1 -1
  42. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  43. package/server/public/app/conversation-view.js +29 -3
  44. package/server/public/app/inbox-view.mjs +1 -1
  45. package/server/public/app/journal-view.js +127 -10
  46. package/server/public/app/model-selector-options.mjs +17 -1
  47. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  48. package/server/public/app/provider-readiness.mjs +5 -2
  49. package/server/public/app/relay-error-ctas.mjs +50 -0
  50. package/server/public/app/relay-schedules-ui.js +1 -1
  51. package/server/public/app/settings-modal.js +11 -0
  52. package/server/public/app/settings-tabs.js +1 -1
  53. package/server/public/app/socket-handlers.js +18 -1
  54. package/server/public/app/tool-activity-emoji.mjs +4 -3
  55. package/server/public/app/usage-limit-ui.js +136 -5
  56. package/server/public/index.html +189 -3
  57. package/server/relay-tools.md +4 -4
  58. package/server/repositories/conversation-group-repository.mjs +1 -1
  59. package/server/repositories/message-repository.mjs +7 -0
  60. package/server/repositories/session-repository.mjs +8 -1
  61. package/server/routes/ask-user-routes.mjs +1 -1
  62. package/server/routes/conversation-group-routes.mjs +1 -1
  63. package/server/routes/messages-routes.mjs +645 -27
  64. package/server/routes/relay-ask-routes.mjs +3 -3
  65. package/server/routes/relay-event-routes.mjs +2 -2
  66. package/server/routes/relay-schedule-routes.mjs +1 -1
  67. package/server/routes/remote-relay-routes.mjs +3 -3
  68. package/server/routes/sessions-routes.mjs +327 -29
  69. package/server/server-runtime.mjs +363 -26
  70. package/server/services/about-service.mjs +1 -0
  71. package/server/services/background-work-view.mjs +2 -1
  72. package/server/services/claude-api-settings-service.mjs +575 -0
  73. package/server/services/claude-api-usage-service.mjs +361 -0
  74. package/server/services/claude-billing-swap-service.mjs +274 -0
  75. package/server/services/claude-billing-switch-service.mjs +343 -0
  76. package/server/services/plan-usage-claude-api.mjs +445 -0
  77. package/server/services/plan-usage-service.mjs +5 -0
  78. package/server/services/provider-model-selection.mjs +3 -2
  79. package/server/services/provider-reasoning-effort.mjs +9 -0
  80. package/server/services/relay-ask-service.mjs +1 -1
  81. package/server/services/relay-event-service.mjs +3 -2
  82. package/server/services/relay-schedule-service.mjs +1 -1
  83. package/server/services/remote-relay-caller-context.mjs +7 -0
  84. package/server/services/remote-relay-dispatcher.mjs +83 -14
  85. package/server/services/remote-relay-registry-service.mjs +1 -1
  86. package/server/services/session-worker-launch-service.mjs +61 -2
  87. package/server/services/session-worker-registry-service.mjs +28 -1
  88. package/server/services/session-worker-websocket-service.mjs +17 -0
  89. package/server/services/usage-limit-pause-service.mjs +55 -4
  90. package/server/services/worker-secret-redaction.mjs +2 -0
  91. package/shared/anthropic-api.mjs +202 -0
  92. package/shared/provider-routing.mjs +5 -3
  93. package/shared/relay-asks.mjs +11 -5
  94. package/shared/relay-events.mjs +2 -2
  95. package/shared/relay-schedules.mjs +2 -2
  96. package/shared/remote-relay-contract.mjs +29 -9
  97. package/shared/remote-relay-tool-core.mjs +23 -6
  98. package/shared/steer-settle-failure.mjs +1 -0
  99. package/shared/worker-runtime/api-client.mjs +2 -2
  100. package/shared/worker-runtime/heartbeat.mjs +5 -0
  101. 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
- export const SESSION_WORKER_PROVIDER_TYPES = Object.freeze(['claude', 'cursor', 'grok', 'claude-cloud', 'deepseek']);
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
  }
@@ -1,4 +1,4 @@
1
- // Questions that don't block: remote_relay's `ask` puts a question for the
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 remote_relay
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 'remote_relay'), `ask_user` (every other card a
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) === 'remote_relay' ? 'approval' : 'ask_user';
150
+ return toText(context.source) === REMOTE_RELAY_APPROVAL_SOURCE ? 'approval' : 'ask_user';
145
151
  }
@@ -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 remote_relay
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 (remote_relay
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 remote_relay's `schedule`
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 remote_relay
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 { RELAY_ASK_LIMITS, RELAY_ASK_URGENCIES, normalizeRelayAskInput } from './relay-asks.mjs';
33
-
34
- export const REMOTE_RELAY_TOOL_NAME = 'remote_relay';
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 takes "opus", "sonnet", "haiku"): '
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 const REMOTE_RELAY_APPROVAL_SOURCE = 'remote_relay';
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 `remote_relay` tool, in one place.
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 `remote_relay` call: validate, then forward
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 remote_relay calls of this conversation the relay is still working
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 (`mcp__relay__remote_relay`, `oar__remote_relay`,
296
- // `oar/remote_relay`, `oar-remote_relay`); all of them are this tool.
297
- const REMOTE_RELAY_NAME_PATTERN = new RegExp(`(?:^|__|[^A-Za-z0-9_])${REMOTE_RELAY_TOOL_NAME}$`, 'i');
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 remote_relay call carries its code there).
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 remote_relay tool) — it goes over node:http without any
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;