@oar-sh/oar 0.9.16 → 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 (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -0,0 +1,150 @@
1
+ // The CLI's routing and trust variables a chat folder's settings must not
2
+ // set, shared by the backends that run Claude Code on a key of the relay's
3
+ // (DeepSeek, Claude API): each pins them in its live flag settings. And the
4
+ // settings both spawn the CLI with: no hooks (CLAUDE_CLI_KEY_CHAT_SETTINGS).
5
+ //
6
+ // The list comes from the bundled CLI and only from it. On every SDK or CLI
7
+ // bump, re-run both proofs (scripts/deepseek-cli-proof.mjs,
8
+ // scripts/claude-api-cli-proof.mjs) and the env-name grep over that binary
9
+ // (fixed names, grep -a, under timeout and nice; never a regex over the
10
+ // whole binary), and update the list and its source line below.
11
+
12
+ /**
13
+ * Every variable through which the env of the chat folder's settings
14
+ * (.claude/settings.json, settings.local.json) could send the CLI's requests,
15
+ * and with them the key, somewhere else, or change whom the CLI trusts on the
16
+ * way: the endpoints, the provider switches, the credentials, the proxies and
17
+ * the TLS trust. The DeepSeek and the Claude API backends pin each one in
18
+ * their live flag settings (liveFlagSettings), which the CLI applies over the
19
+ * folder's, to the value the CLI was spawned with: the worker's own, else the
20
+ * other case's for a proxy variable, else ''.
21
+ * '' is unset to the CLI for each of these (truthy or trimmed reads), except
22
+ * SSL_CERT_FILE and SSL_CERT_DIR: '' empties OpenSSL's and Node's system
23
+ * store, so those two are pinned only when the worker sets them.
24
+ *
25
+ * Source: the names Claude Code 2.1.304 (the bundled CLI) reads, by a grep
26
+ * of the binary; the CLI's own lists of endpoints (with their selection
27
+ * switches and companions) and credentials are in it as they are there. A
28
+ * deny-list, because a settings layer can only pin names known in advance.
29
+ * Left out, each for its reason: the model variables (same endpoint, same
30
+ * key), what the CLI already drops from a folder's settings or from all
31
+ * settings (CLAUDE_CONFIG_DIR, CLAUDE_CODE_CUSTOM_OAUTH_URL, the OTEL
32
+ * exporter endpoints, ANTHROPIC_UNIX_SOCKET, the host-managed provider
33
+ * switches, the file-descriptor credentials), a provider's own SDK settings
34
+ * (AWS_*, GOOGLE_*, AZURE_*, read only for the provider the switches here
35
+ * turn off), NODE_OPTIONS (at runtime the CLI reads only its system-CA
36
+ * flags, which add no CA), and what routes only other programs' traffic
37
+ * (NPM_CONFIG_*, GIT_*, the resolver's variables).
38
+ */
39
+ export const CLAUDE_CLI_PINNED_ENV = Object.freeze([
40
+ // Endpoints: the CLI's list, its fallback and the headers sent with them.
41
+ 'ANTHROPIC_BASE_URL',
42
+ '_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
43
+ 'ANTHROPIC_CUSTOM_HEADERS',
44
+ 'ANTHROPIC_BEDROCK_BASE_URL',
45
+ 'ANTHROPIC_VERTEX_BASE_URL',
46
+ 'ANTHROPIC_FOUNDRY_BASE_URL',
47
+ 'ANTHROPIC_AWS_BASE_URL',
48
+ 'ANTHROPIC_GOOGLE_CLOUD_BASE_URL',
49
+ 'ANTHROPIC_BEDROCK_MANTLE_BASE_URL',
50
+ 'CLAUDE_CODE_API_BASE_URL',
51
+ 'CLAUDE_CODE_ARTIFACTS_API_BASE_URL',
52
+ 'CLAUDE_CODE_ARTIFACTS_API_TOKEN',
53
+ 'CLAUDE_CODE_ARTIFACT_ASSET_BASE_URL',
54
+ 'CLAUDE_CODE_ARTIFACT_LIVE_BASE_URL',
55
+ 'CLAUDE_CODE_ARTIFACT_SYNC_BASE_URL',
56
+ 'CLAUDE_CODE_ARTIFACT_VIEWER_BASE_URL',
57
+ 'CLAUDE_CODE_MEMORY_API_BASE_URL',
58
+ 'CLAUDE_CODE_MEMORY_API_TOKEN',
59
+ // Provider switches.
60
+ 'CLAUDE_CODE_USE_BEDROCK',
61
+ 'CLAUDE_CODE_USE_VERTEX',
62
+ 'CLAUDE_CODE_USE_FOUNDRY',
63
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
64
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
65
+ 'CLAUDE_CODE_USE_MANTLE',
66
+ 'CLAUDE_CODE_USE_GATEWAY',
67
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
68
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
69
+ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
70
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH',
71
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH',
72
+ 'CLAUDE_CODE_SKIP_MANTLE_AUTH',
73
+ // Credentials: the CLI's list, the OAuth login, the federation profile and
74
+ // the proxy credential helper.
75
+ 'ANTHROPIC_API_KEY',
76
+ 'ANTHROPIC_AUTH_TOKEN',
77
+ 'CLAUDE_CODE_OAUTH_TOKEN',
78
+ 'AWS_BEARER_TOKEN_BEDROCK',
79
+ 'ANTHROPIC_FOUNDRY_API_KEY',
80
+ 'ANTHROPIC_FOUNDRY_AUTH_TOKEN',
81
+ 'ANTHROPIC_AWS_API_KEY',
82
+ 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
83
+ 'CLAUDE_CODE_OAUTH_SCOPES',
84
+ 'CLAUDE_CODE_OAUTH_CLIENT_ID',
85
+ 'ANTHROPIC_PROFILE',
86
+ 'ANTHROPIC_IDENTITY_TOKEN',
87
+ 'ANTHROPIC_IDENTITY_TOKEN_FILE',
88
+ 'ANTHROPIC_FEDERATION_RULE_ID',
89
+ 'ANTHROPIC_ORGANIZATION_ID',
90
+ 'ANTHROPIC_SERVICE_ACCOUNT_ID',
91
+ 'ANTHROPIC_SCOPE',
92
+ 'ANTHROPIC_WORKSPACE_ID',
93
+ 'CLAUDE_CODE_ENABLE_PROXY_AUTH_HELPER',
94
+ // Proxies: the CLI reads both cases, the lower one first.
95
+ 'HTTPS_PROXY',
96
+ 'https_proxy',
97
+ 'HTTP_PROXY',
98
+ 'http_proxy',
99
+ 'ALL_PROXY',
100
+ 'all_proxy',
101
+ 'NO_PROXY',
102
+ 'no_proxy',
103
+ 'CLAUDE_CODE_HTTPS_PROXY',
104
+ 'CLAUDE_CODE_HTTP_PROXY',
105
+ // TLS trust and the client certificate.
106
+ 'NODE_TLS_REJECT_UNAUTHORIZED',
107
+ 'NODE_EXTRA_CA_CERTS',
108
+ 'CLAUDE_CODE_CERT_STORE',
109
+ 'CLAUDE_CODE_CLIENT_CERT',
110
+ 'CLAUDE_CODE_CLIENT_KEY',
111
+ 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE',
112
+ 'SSL_CERT_FILE',
113
+ 'SSL_CERT_DIR',
114
+ ]);
115
+ // The two of CLAUDE_CLI_PINNED_ENV for which '' is not unset (see there).
116
+ const PINNED_ONLY_WHEN_SET = new Set(['SSL_CERT_FILE', 'SSL_CERT_DIR']);
117
+ // The proxy variables the CLI and the tools it runs read in either case.
118
+ const OTHER_CASE = new Map(
119
+ ['HTTPS_PROXY', 'HTTP_PROXY', 'ALL_PROXY', 'NO_PROXY'].flatMap((name) => [
120
+ [name, name.toLowerCase()],
121
+ [name.toLowerCase(), name],
122
+ ]),
123
+ );
124
+
125
+ /**
126
+ * The settings both backends spawn every CLI with, on its command line (their
127
+ * flagSettings): no hooks and no statusLine command, neither the user's nor
128
+ * the chat folder's nor a plugin's. Those run as the relay's user with the
129
+ * CLI's environment, the key in it, and a folder's SessionStart hook would run
130
+ * before the live pins land. The SDK has the spawn settings in force before
131
+ * any hook (sdk.d.ts, Options.settings; scripts/claude-cli-hooks-proof.mjs
132
+ * shows it). Hooks in managed settings still run: those are the host admin's.
133
+ * OAR's Claude worker registers no SDK hooks of its own.
134
+ */
135
+ export const CLAUDE_CLI_KEY_CHAT_SETTINGS = Object.freeze({ disableAllHooks: true });
136
+
137
+ /**
138
+ * The pins of CLAUDE_CLI_PINNED_ENV for a CLI spawned with `cliEnv`: the
139
+ * value it was spawned with, else the other case's (a proxy the worker sets
140
+ * as https_proxy holds for HTTPS_PROXY as well), else ''.
141
+ */
142
+ export function claudeCliPinnedEnv(cliEnv = {}) {
143
+ const pins = {};
144
+ for (const name of CLAUDE_CLI_PINNED_ENV) {
145
+ const own = cliEnv[name] ?? cliEnv[OTHER_CASE.get(name)];
146
+ if (own !== undefined) pins[name] = String(own);
147
+ else if (!PINNED_ONLY_WHEN_SET.has(name)) pins[name] = '';
148
+ }
149
+ return pins;
150
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The relay's `remote_relay` tool for the Claude worker, shaped as an SDK MCP
2
+ * The relay's `relay_tool` tool for the Claude worker, shaped as an SDK MCP
3
3
  * tool definition (`{ name, description, inputSchema, handler }`) so the
4
4
  * adapter can hand it to `createSdkMcpServer` next to the preview tool. On the
5
- * wire it is `mcp__relay__remote_relay`; the module never imports the SDK.
5
+ * wire it is `mcp__relay__relay_tool`; the module never imports the SDK.
6
6
  */
7
7
 
8
8
  import * as z from 'zod';
@@ -140,7 +140,7 @@ export function createRemoteRelayToolDefinition({ api, getConversationId = () =>
140
140
  // A tool exception would surface as a failed MCP call and can abort
141
141
  // the turn; a refusal the model can read cannot.
142
142
  const message = error?.message || String(error);
143
- dbg('remote_relay tool failed', message);
143
+ dbg('relay_tool failed', message);
144
144
  result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
145
145
  }
146
146
  return { content: [{ type: 'text', text: formatRemoteRelayToolResult(result) }] };
@@ -6,9 +6,12 @@ import { MEDIA_EMBED_INSTRUCTION_TEXT } from '../../shared/media-embed-instructi
6
6
  import { REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS } from '../../shared/remote-relay-tool-core.mjs';
7
7
  import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
8
8
  import { createRemoteRelayToolDefinition } from './claude-remote-relay-tool.mjs';
9
+ import { CLAUDE_LIVE_FLAG_SETTINGS_CODE, CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE } from './claude-turn-failure.mjs';
10
+
11
+ export { CLAUDE_LIVE_FLAG_SETTINGS_CODE };
9
12
 
10
13
  // The name the in-process MCP server is registered under; it prefixes every
11
- // tool it carries on the wire (`mcp__relay__preview`, `mcp__relay__remote_relay`).
14
+ // tool it carries on the wire (`mcp__relay__preview`, `mcp__relay__relay_tool`).
12
15
  export const RELAY_MCP_SERVER_NAME = 'relay';
13
16
 
14
17
  // The per-server tool-call timeout the CLI applies to the relay's tools.
@@ -16,7 +19,7 @@ export const RELAY_MCP_SERVER_NAME = 'relay';
16
19
  // value, else by the MCP_TOOL_TIMEOUT environment variable, else by 1e8 ms —
17
20
  // and SDK servers are exempt from the idle timeout. Pinning it here keeps a
18
21
  // host-wide MCP_TOOL_TIMEOUT (set for some other server) from cutting off a
19
- // remote_relay wait: those run up to wait_seconds (≤ 600 s) plus however long
22
+ // relay_tool wait: those run up to wait_seconds (≤ 600 s) plus however long
20
23
  // an approval card sits unanswered.
21
24
  export const RELAY_MCP_TOOL_TIMEOUT_MS = REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS;
22
25
 
@@ -119,6 +122,23 @@ export function claudeSpawnSettings({ ultracode = false, autoCompactWindow = nul
119
122
  return Object.keys(settings).length ? settings : null;
120
123
  }
121
124
 
125
+ /**
126
+ * The spawn's `settings` with a backend's flag settings on top: null when
127
+ * neither has anything, the relay's object untouched when the backend has
128
+ * nothing to add. `env` is merged by name, so neither side drops the other's
129
+ * variables.
130
+ */
131
+ export function mergeSpawnSettings(spawnSettings, backendSettings) {
132
+ const own = spawnSettings && typeof spawnSettings === 'object' ? spawnSettings : null;
133
+ const extra = backendSettings && typeof backendSettings === 'object' && Object.keys(backendSettings).length
134
+ ? backendSettings
135
+ : null;
136
+ if (!extra) return own;
137
+ const merged = { ...(own || {}), ...extra };
138
+ if (own?.env || extra.env) merged.env = { ...(own?.env || {}), ...(extra.env || {}) };
139
+ return merged;
140
+ }
141
+
122
142
  /**
123
143
  * Live twin of the attribution spawn setting, for a value (OAR or off). A
124
144
  * running CLI takes a new text through the flag layer (it arrives as a fresh
@@ -206,7 +226,7 @@ export function systemPromptForRelayMode(relayMode, { backgroundCommandLimitMs =
206
226
  * payload. `getConversationId` is called per tool call because a process
207
227
  * outlives the turn it was spawned for.
208
228
  *
209
- * `remote_relay` is the exception: it rides along only when the worker found
229
+ * `relay_tool` is the exception: it rides along only when the worker found
210
230
  * at least one paired remote (`remoteRelayTool`), because a tool that can do
211
231
  * nothing on a relay without remotes would only cost context on every turn.
212
232
  */
@@ -229,14 +249,25 @@ export function createRelayMcpServer({ api, getConversationId, dbg, remoteRelayT
229
249
  * emitted, which also closes the control transport (canUseTool, context
230
250
  * usage), so the session process keeps this open while background tasks or
231
251
  * queued continuations still need the process alive.
252
+ *
253
+ * `held`: nothing is sent until `release()` (the messages pushed meanwhile
254
+ * wait in order). `fail(error)` instead makes the stream throw it, which the
255
+ * SDK answers by aborting the query; nothing held is ever sent, and neither
256
+ * is it when the stream ends while still held.
232
257
  */
233
- function createPushableUserMessageStream() {
258
+ function createPushableUserMessageStream({ held = false } = {}) {
234
259
  const queued = [];
235
260
  let wake = null;
236
261
  let ended = false;
262
+ let holding = held;
263
+ let failure = null;
237
264
  async function* stream() {
238
265
  for (;;) {
239
- while (queued.length) yield queued.shift();
266
+ if (failure) throw failure;
267
+ if (!holding) {
268
+ while (queued.length && !failure) yield queued.shift();
269
+ if (failure) continue;
270
+ }
240
271
  if (ended) return;
241
272
  await new Promise((resolve) => { wake = resolve; });
242
273
  wake = null;
@@ -257,9 +288,80 @@ function createPushableUserMessageStream() {
257
288
  ended = true;
258
289
  wake?.();
259
290
  },
291
+ release() {
292
+ holding = false;
293
+ wake?.();
294
+ },
295
+ fail(error) {
296
+ failure = error;
297
+ wake?.();
298
+ },
260
299
  };
261
300
  }
262
301
 
302
+ /** Every string in a settings object, for taking them out of a text. */
303
+ function settingsStrings(value, found = []) {
304
+ if (typeof value === 'string') {
305
+ if (value) found.push(value);
306
+ } else if (value && typeof value === 'object') {
307
+ for (const item of Object.values(value)) settingsStrings(item, found);
308
+ }
309
+ return found;
310
+ }
311
+
312
+ /**
313
+ * The blanked keys of a backend's live flag settings (top-level and `env`
314
+ * values that are ''), for the spawn's `settings` too: an empty value holds
315
+ * no secret, and the CLI acts on some settings at startup, before the live
316
+ * ones land (it runs a folder's apiKeyHelper then; CLI 2.1.304,
317
+ * scripts/deepseek-cli-proof.mjs). Null when there is none.
318
+ */
319
+ function liveSettingsBlanks(live) {
320
+ if (!live) return null;
321
+ const blanks = {};
322
+ for (const [key, value] of Object.entries(live)) {
323
+ if (value === '') blanks[key] = '';
324
+ }
325
+ const env = Object.entries(live.env && typeof live.env === 'object' ? live.env : {}).filter(([, value]) => value === '');
326
+ if (env.length) blanks.env = Object.fromEntries(env);
327
+ return Object.keys(blanks).length ? blanks : null;
328
+ }
329
+
330
+ /** The spawn's `settings` with the live settings' blanks on top. */
331
+ function withLiveSettingsBlanks(spawnSettings, live) {
332
+ const blanks = liveSettingsBlanks(live);
333
+ if (!blanks) return spawnSettings;
334
+ const settings = { ...(spawnSettings || {}), ...blanks };
335
+ if (spawnSettings?.env || blanks.env) settings.env = { ...(spawnSettings?.env || {}), ...(blanks.env || {}) };
336
+ return settings;
337
+ }
338
+
339
+ /**
340
+ * How long the CLI may take to answer a backend's live flag settings (ms).
341
+ * It answers in well under a second (CLI 2.1.304); one that never does fails
342
+ * the turn like a refusal, and the held message is never sent.
343
+ */
344
+ export const LIVE_FLAG_SETTINGS_TIMEOUT_MS = 60_000;
345
+
346
+ /**
347
+ * The Error a spawn fails with when the CLI refused its live flag settings.
348
+ * The message names no value; `detail` is the CLI's own words with every
349
+ * value of the settings taken out.
350
+ */
351
+ function liveFlagSettingsError(cause, settings, label = '') {
352
+ const error = new Error(
353
+ `Claude Code did not take the ${label ? `${label} ` : ''}connection settings before the first message, so nothing was sent.`,
354
+ );
355
+ error.code = CLAUDE_LIVE_FLAG_SETTINGS_CODE;
356
+ error.guidance = CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE;
357
+ let detail = String(cause?.message || cause || '');
358
+ for (const value of settingsStrings(settings).sort((a, b) => b.length - a.length)) {
359
+ detail = detail.split(value).join('[redacted]');
360
+ }
361
+ error.detail = detail.slice(0, 200);
362
+ return error;
363
+ }
364
+
263
365
  /**
264
366
  * The only module that imports `@anthropic-ai/claude-agent-sdk`.
265
367
  *
@@ -290,37 +392,75 @@ export function startClaudeSession({
290
392
  pathToClaudeCodeExecutable = '',
291
393
  api = null,
292
394
  getConversationId = () => '',
293
- // Whether this process carries `remote_relay` (the worker's registration
395
+ // Whether this process carries `relay_tool` (the worker's registration
294
396
  // decision). Fixed for the process: the tool list is part of the spawn.
295
397
  remoteRelayTool = false,
398
+ // Another endpoint for the CLI (null = Anthropic through the host's Claude
399
+ // login): it builds the CLI's environment and maps model and effort into
400
+ // its terms (../deepseek-worker/deepseek-claude-backend.mjs).
401
+ backend = null,
402
+ liveFlagSettingsTimeoutMs = LIVE_FLAG_SETTINGS_TIMEOUT_MS,
296
403
  queryImpl = query,
297
404
  dbg = () => {},
298
405
  } = {}) {
299
406
  const normalizedModel = String(model || '').trim().toLowerCase();
300
- const effort = normalizeClaudeEffort(reasoningEffort);
407
+ // A backend on Claude's own models (the Claude API backend) takes the
408
+ // model and the effort exactly as Claude does; only another provider's
409
+ // backend maps them into its terms.
410
+ const modelBackend = backend && backend.claudeModels !== true ? backend : null;
411
+ const effort = modelBackend ? '' : normalizeClaudeEffort(reasoningEffort);
301
412
  const ultracode = effort === CLAUDE_ULTRACODE_EFFORT;
302
- const effortOption = ultracode ? 'xhigh' : effort;
413
+ const effortOption = modelBackend ? modelBackend.effortOption(reasoningEffort) : (ultracode ? 'xhigh' : effort);
414
+ const cliModel = modelBackend ? modelBackend.cliModel(model) : String(model || '').trim();
303
415
  // Spawn-time twin of the flag-settings helpers: the settings layer is the
304
416
  // only way to hand a fresh CLI the session-scoped ultracode flag, the
305
417
  // auto-compact window and the thinking pin, and all three share one
306
- // `settings` object.
307
- const spawnSettings = claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled, attribution });
418
+ // `settings` object. A backend's own pins (no hooks for both, the Claude
419
+ // API backend's endpoint and blanked credentials) join them; theirs win a
420
+ // clash.
421
+ const spawnSettings = mergeSpawnSettings(
422
+ claudeSpawnSettings({ ultracode, autoCompactWindow, thinkingEnabled, attribution }),
423
+ typeof backend?.flagSettings === 'function' ? backend.flagSettings() : null,
424
+ );
308
425
  // Relay tools need the worker's authenticated API helper; a caller that
309
426
  // passes none (tests, probes) gets a session without them.
310
427
  const relayMcpServer = typeof api === 'function'
311
428
  ? createRelayMcpServer({ api, getConversationId, dbg, remoteRelayTool: remoteRelayTool === true })
312
429
  : null;
313
- const env = claudeSpawnEnv({ backgroundCommandLimitMs });
430
+ // A backend's CLI always gets an environment of its own: the worker's
431
+ // carries the backend's secret and maybe the host's Anthropic settings.
432
+ // The model goes in as well: the backend pins Claude Code's model aliases to
433
+ // it, so a sub-agent asked for as "opus" runs on the session's model. The
434
+ // Bash limits go on top: the backend drops a host session's Bash cap, not
435
+ // the relay's own.
436
+ const backendEnv = backend ? backend.spawnEnv(process.env, { effort: reasoningEffort, model }) : null;
437
+ const limitEnv = claudeSpawnEnv({ backgroundCommandLimitMs, baseEnv: backendEnv || process.env });
438
+ const env = limitEnv || backendEnv;
439
+ // A backend's pins that may not go on the command line (a key): `settings`
440
+ // below is the CLI's `--settings` argument, which other users of the host
441
+ // can read. These go into the same flag layer over the control channel
442
+ // (stdin), and no user message is sent before the CLI has taken them: the
443
+ // first API request of the process comes after (measured on CLI 2.1.304,
444
+ // the CLI proofs in scripts/). The layer outranks the user's, the
445
+ // project's and the local settings and holds through their reloads and
446
+ // through later applyFlagSettings calls of other keys. A refusal ends the
447
+ // process before anything is sent (CLAUDE_LIVE_FLAG_SETTINGS_CODE), and so
448
+ // does no answer within liveFlagSettingsTimeoutMs. Their blanks go on the
449
+ // command line as well, for what the CLI does at startup. They are built
450
+ // from the CLI's environment: a pin holds the value the CLI starts with.
451
+ const liveSettings = typeof backend?.liveFlagSettings === 'function' ? backend.liveFlagSettings(env || process.env) : null;
452
+ const live = liveSettings && typeof liveSettings === 'object' && Object.keys(liveSettings).length ? liveSettings : null;
453
+ const settings = withLiveSettingsBlanks(spawnSettings, live);
314
454
  const options = {
315
455
  cwd,
316
456
  permissionMode: permissionModeForRelayMode(relayMode),
317
- systemPrompt: systemPromptForRelayMode(relayMode, { backgroundCommandLimitMs: env ? backgroundCommandLimitMs : null }),
457
+ systemPrompt: systemPromptForRelayMode(relayMode, { backgroundCommandLimitMs: limitEnv ? backgroundCommandLimitMs : null }),
318
458
  includePartialMessages: true,
319
459
  forwardSubagentText: true,
320
460
  ...(effortOption ? { effort: effortOption } : {}),
321
- ...(spawnSettings ? { settings: spawnSettings } : {}),
461
+ ...(settings ? { settings } : {}),
322
462
  ...(env ? { env } : {}),
323
- ...(normalizedModel && normalizedModel !== 'auto' ? { model: String(model).trim() } : {}),
463
+ ...(normalizedModel && normalizedModel !== 'auto' && cliModel ? { model: cliModel } : {}),
324
464
  ...(String(resume || '').trim() ? { resume: String(resume).trim() } : {}),
325
465
  ...(abortController ? { abortController } : {}),
326
466
  ...(typeof canUseTool === 'function' ? { canUseTool } : {}),
@@ -329,8 +469,52 @@ export function startClaudeSession({
329
469
  ? { pathToClaudeCodeExecutable: String(pathToClaudeCodeExecutable).trim() }
330
470
  : {}),
331
471
  };
332
- const { stream, push, end } = createPushableUserMessageStream();
472
+ const { stream, push, end, release, fail } = createPushableUserMessageStream({ held: Boolean(live) });
333
473
  const turn = queryImpl({ prompt: stream, options });
474
+ if (live) {
475
+ // The wait ends with the answer, the timeout or the session's abort,
476
+ // whichever comes first; each one clears the others.
477
+ const signal = abortController?.signal || null;
478
+ let timer = null;
479
+ let onAbort = null;
480
+ const settled = () => {
481
+ clearTimeout(timer);
482
+ timer = null;
483
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
484
+ onAbort = null;
485
+ };
486
+ const unanswered = new Promise((resolve, reject) => {
487
+ const waitMs = Math.max(0, Number(liveFlagSettingsTimeoutMs) || 0);
488
+ timer = setTimeout(() => {
489
+ timer = null;
490
+ reject(new Error(`no answer within ${Math.ceil(waitMs / 1000)} s`));
491
+ }, waitMs);
492
+ timer.unref?.();
493
+ if (signal) {
494
+ onAbort = settled;
495
+ if (signal.aborted) settled();
496
+ else signal.addEventListener('abort', onAbort, { once: true });
497
+ }
498
+ });
499
+ turn.liveFlagSettingsApplied = Promise.race([
500
+ Promise.resolve().then(() => {
501
+ if (typeof turn.applyFlagSettings !== 'function') throw new Error('applyFlagSettings is not available');
502
+ return turn.applyFlagSettings(live);
503
+ }),
504
+ unanswered,
505
+ ])
506
+ .finally(settled)
507
+ .then(() => {
508
+ release();
509
+ return true;
510
+ }, (error) => {
511
+ const refused = liveFlagSettingsError(error, live, backend?.label || '');
512
+ turn.liveFlagSettingsError = refused;
513
+ dbg('live flag settings refused', refused.detail);
514
+ fail(refused);
515
+ return false;
516
+ });
517
+ }
334
518
  applyThinkingDisplay(turn, thinkingDisplay, { dbg });
335
519
  turn.pushUserMessage = push;
336
520
  // The session process MUST call this on every teardown path or the CLI