@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
@@ -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']);
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.
@@ -85,6 +85,8 @@ export function steerAgentLabelForProvider(providerType) {
85
85
  case 'cursor': return 'Cursor';
86
86
  case 'grok': return 'Grok';
87
87
  case 'claude-cloud': return 'Claude Cloud';
88
+ case 'deepseek': return 'DeepSeek';
89
+ case 'claude-api': return 'Claude API';
88
90
  default: return DEFAULT_STEER_AGENT_LABEL;
89
91
  }
90
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
  */
@@ -0,0 +1,128 @@
1
+ // A turn on an OpenAI image model (`gpt-image-*`, `dall-e-*`) is no chat
2
+ // turn: no chat runtime can serve it, and one that is handed it fails ("No
3
+ // available capacity was found for the model", or a request rejected as
4
+ // invalid). Both Copilot engines therefore send it straight to the relay's
5
+ // image routes instead, and this module is the one copy of that path.
6
+ //
7
+ // With image continuity on (the default) the relay created an image operation
8
+ // when the message was sent, and `POST /api/image-operations/:id/execute` does
9
+ // everything: the provider call, the generated-image reply, and on a failure
10
+ // the failure note or the requeue. The caller only reports the outcome. Without
11
+ // an operation the worker calls `POST /api/openai/images/generate` and
12
+ // publishes the reply itself.
13
+
14
+ export const DIRECT_IMAGE_OUTCOMES = Object.freeze(['completed', 'executing', 'terminal', 'uncertain', 'retryable']);
15
+
16
+ // The code of the error thrown when the provider refused the request outright:
17
+ // a retry would be refused the same way, so the turn fails instead of being
18
+ // requeued.
19
+ export const OPENAI_IMAGE_TERMINAL_CODE = 'RELAY_OPENAI_IMAGE_TERMINAL';
20
+
21
+ export function shouldUseDirectOpenAIImageApi(message = {}) {
22
+ const providerType = String(message?.providerType || '').trim().toLowerCase();
23
+ if (providerType !== 'openai') return false;
24
+ const model = String(message?.providerModel || message?.model || '').trim().toLowerCase();
25
+ return model.startsWith('gpt-image-') || model.startsWith('dall-e-');
26
+ }
27
+
28
+ export function isDirectOpenAIImageTerminalStatus(status) {
29
+ const numeric = Number(status);
30
+ if (!Number.isFinite(numeric)) return false;
31
+ return [400, 401, 403, 404, 409, 422, 429, 503].includes(numeric);
32
+ }
33
+
34
+ export function isOpenAIImageTerminalError(error) {
35
+ return String(error?.code || '').trim() === OPENAI_IMAGE_TERMINAL_CODE;
36
+ }
37
+
38
+ const isAutoModel = (value) => String(value || '').trim().toLowerCase() === 'auto';
39
+
40
+ /**
41
+ * Run one image turn. `requestOptions` goes to every `api` call that may wait
42
+ * on the provider (a worker passes `{ longCall: true }`: an edit can take
43
+ * minutes); `responseFields` is merged into the reply the worker publishes
44
+ * itself (the attempt fence).
45
+ *
46
+ * Resolves `{ via: 'operation', outcome }` or `{ via: 'direct', outcome:
47
+ * 'completed', imageCount, model }`. Throws an error with `code`
48
+ * `OPENAI_IMAGE_TERMINAL_CODE` when the provider refused the direct request,
49
+ * and a plain error for anything else (the caller requeues).
50
+ */
51
+ export async function runDirectOpenAIImageTurn({
52
+ api,
53
+ message,
54
+ requestOptions = {},
55
+ responseFields = {},
56
+ } = {}) {
57
+ if (message?.imageOperationId) {
58
+ const result = await api(
59
+ 'POST',
60
+ `/api/image-operations/${encodeURIComponent(message.imageOperationId)}/execute`,
61
+ { messageId: message.id },
62
+ requestOptions,
63
+ ).catch((error) => {
64
+ // The route answers a failure it already handled (published or
65
+ // requeued) with 409 or 503 and the outcome in the body.
66
+ if (error?.status === 409 || error?.status === 503) {
67
+ return error?.body || error?.payload || { outcome: error?.status === 503 ? 'retryable' : 'terminal' };
68
+ }
69
+ throw error;
70
+ });
71
+ const outcome = String(result?.outcome || '').trim().toLowerCase();
72
+ if (!DIRECT_IMAGE_OUTCOMES.includes(outcome)) {
73
+ throw new Error('Image operation returned an invalid outcome');
74
+ }
75
+ return { via: 'operation', outcome };
76
+ }
77
+
78
+ const requestedModel = isAutoModel(message?.model) ? '' : String(message?.model || '').trim();
79
+ const directModel = String(message?.providerModel || requestedModel || message?.model || '').trim();
80
+ const prompt = String(message?.text || '').trim();
81
+ if (!directModel) throw new Error('OpenAI image request is missing model');
82
+ if (!prompt) throw new Error('OpenAI image request is missing prompt');
83
+ await api('POST', '/api/activity', {
84
+ messageId: message.id,
85
+ conversationId: message.conversationId,
86
+ mode: message.relayMode || 'agent',
87
+ text: `Calling OpenAI image API directly (${directModel})`,
88
+ }).catch(() => {});
89
+ let directResult;
90
+ try {
91
+ directResult = await api('POST', '/api/openai/images/generate', {
92
+ messageId: message.id,
93
+ conversationId: message.conversationId,
94
+ model: directModel,
95
+ prompt,
96
+ n: message?.n,
97
+ size: message?.size || message?.contextTier,
98
+ quality: message?.quality || message?.reasoningEffort,
99
+ attachments: Array.isArray(message?.attachments) ? message.attachments : [],
100
+ }, requestOptions);
101
+ } catch (error) {
102
+ if (isDirectOpenAIImageTerminalStatus(error?.status)) {
103
+ const terminalError = new Error(
104
+ `OpenAI image request failed: ${error?.detail || `HTTP ${error?.status || 'error'}`}`,
105
+ );
106
+ terminalError.code = OPENAI_IMAGE_TERMINAL_CODE;
107
+ terminalError.status = error?.status;
108
+ terminalError.detail = error?.detail || null;
109
+ throw terminalError;
110
+ }
111
+ throw error;
112
+ }
113
+ const generatedImages = Array.isArray(directResult?.generatedImages) ? directResult.generatedImages : [];
114
+ if (!generatedImages.length) {
115
+ throw new Error('OpenAI image API returned no images');
116
+ }
117
+ const model = String(directResult?.model || directModel).trim() || directModel;
118
+ await api('POST', '/api/response', {
119
+ messageId: message.id,
120
+ conversationId: message.conversationId,
121
+ text: '',
122
+ generatedImages,
123
+ model,
124
+ modelOrigin: isAutoModel(message?.model) ? 'auto' : 'manual',
125
+ ...responseFields,
126
+ });
127
+ return { via: 'direct', outcome: 'completed', imageCount: generatedImages.length, model };
128
+ }
@@ -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
@@ -95,3 +95,63 @@ export function installWorkerLogFile({
95
95
  },
96
96
  };
97
97
  }
98
+
99
+ const REDACTED_SECRET_TEXT = '[redacted]';
100
+ // As the relay's redaction of worker posts (server/services/worker-secret-redaction.mjs):
101
+ // a shorter value would mangle ordinary text.
102
+ const MIN_REDACTED_SECRET_LENGTH = 8;
103
+
104
+ /**
105
+ * Replaces each secret's exact text with `[redacted]` in what the worker
106
+ * writes to stdout/stderr, on every platform: the console, the Linux
107
+ * launcher's redirect into the worker log, and the Windows copy above (install
108
+ * this after installWorkerLogFile so the copy gets the redacted text). A
109
+ * secret split across two writes is not caught; the worker logs whole lines.
110
+ * Returns null when there is no secret of at least 8 characters.
111
+ */
112
+ export function installWorkerLogRedaction({
113
+ secrets = [],
114
+ processImpl = process,
115
+ } = {}) {
116
+ const values = [...new Set((Array.isArray(secrets) ? secrets : [])
117
+ .map((value) => String(value ?? '').trim())
118
+ .filter((value) => value.length >= MIN_REDACTED_SECRET_LENGTH))]
119
+ .sort((a, b) => b.length - a.length);
120
+ if (!values.length) return null;
121
+
122
+ const redact = (text) => {
123
+ let output = text;
124
+ for (const value of values) {
125
+ if (output.includes(value)) output = output.split(value).join(REDACTED_SECRET_TEXT);
126
+ }
127
+ return output;
128
+ };
129
+
130
+ const restores = [];
131
+ for (const stream of [processImpl.stdout, processImpl.stderr]) {
132
+ if (!stream || typeof stream.write !== 'function') continue;
133
+ const originalWrite = stream.write;
134
+ const redactingWrite = function redactingWrite(chunk, encoding, callback) {
135
+ if (typeof chunk === 'string') {
136
+ return originalWrite.call(this, redact(chunk), encoding, callback);
137
+ }
138
+ if (chunk instanceof Uint8Array) {
139
+ const bytes = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
140
+ if (values.some((value) => bytes.includes(value))) {
141
+ const done = typeof encoding === 'function' ? encoding : callback;
142
+ return originalWrite.call(this, redact(bytes.toString('utf8')), 'utf8', done);
143
+ }
144
+ }
145
+ return originalWrite.call(this, chunk, encoding, callback);
146
+ };
147
+ stream.write = redactingWrite;
148
+ restores.push(() => {
149
+ if (stream.write === redactingWrite) stream.write = originalWrite;
150
+ });
151
+ }
152
+ return {
153
+ uninstall() {
154
+ for (const restore of restores) restore();
155
+ },
156
+ };
157
+ }
@@ -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;