@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
package/README.md CHANGED
@@ -17,7 +17,7 @@ On Windows, run `irm oar.sh/install.ps1 | iex` in PowerShell. Other ways to inst
17
17
 
18
18
  ## What OAR does
19
19
 
20
- Coding agents such as GitHub Copilot, Claude Code, Cursor and Grok usually live in a terminal. OAR puts them into a chat you can open anywhere: start a task at your desk, follow it from the sofa, answer the agent's question from your phone. The agents keep running on your own computer, with your files and your logins; OAR passes the conversation back and forth.
20
+ Coding agents such as GitHub Copilot, Claude Code, Cursor, Grok and DeepSeek usually live in a terminal. OAR puts them into a chat you can open anywhere: start a task at your desk, follow it from the sofa, answer the agent's question from your phone. The agents keep running on your own computer, with your files and your logins; OAR passes the conversation back and forth.
21
21
 
22
22
  ```mermaid
23
23
  flowchart LR
@@ -27,7 +27,7 @@ flowchart LR
27
27
  end
28
28
  subgraph host["Your computer"]
29
29
  relay["OAR relay<br/>chats, queue, history, files"]
30
- agents["Copilot · OpenAI · Claude<br/>Cursor · Grok"]
30
+ agents["Copilot · OpenAI · Claude · Claude API<br/>Cursor · Grok · DeepSeek"]
31
31
  end
32
32
  cloud["☁️ Claude Cloud<br/>sandbox at Anthropic"]
33
33
  phone <--> relay
@@ -36,7 +36,7 @@ flowchart LR
36
36
  relay <--> cloud
37
37
  ```
38
38
 
39
- - **Seven runtimes, one chat**: you pick the agent per conversation, and all of them share the same chat, queue, history, file browser, and question cards ([Runtimes](#runtimes)).
39
+ - **Eight runtimes, one chat**: you pick the agent per conversation, and all of them share the same chat, queue, history, file browser, and question cards ([Runtimes](#runtimes)).
40
40
  - **Yours**: the relay runs on your hardware, answers only on this machine until you change that, and collects no telemetry ([Security notes](#security-notes)).
41
41
  - **Under active development**: expect occasional rough edges, and some provider SDK features that are missing or incomplete.
42
42
 
@@ -45,29 +45,29 @@ flowchart LR
45
45
  <table>
46
46
  <tr>
47
47
  <td width="42%" rowspan="4" valign="top">
48
- <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.16" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
48
+ <a href="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-mobile.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-mobile.png?v=0.9.18" alt="OAR on a phone: a finished agent turn in a conversation, with the composer below"></picture></a>
49
49
  <br><sub>On a phone: the same conversations, installable as an app.</sub>
50
50
  </td>
51
51
  <td width="58%" valign="top">
52
- <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.16" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
52
+ <a href="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-agents.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-agents.png?v=0.9.18" alt="Background subagents running in parallel in the task panel, each with live token counts, elapsed time, and its own Stop button"></picture></a>
53
53
  <br><sub>Agents working in parallel, each with live token counts and its own Stop.</sub>
54
54
  </td>
55
55
  </tr>
56
56
  <tr>
57
57
  <td valign="top">
58
- <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.16" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
58
+ <a href="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-question.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-question.png?v=0.9.18" alt="A question card in the middle of a turn: the agent offers several choices and a free-text reply"></picture></a>
59
59
  <br><sub>When the agent needs a decision, it asks, and waits for your answer.</sub>
60
60
  </td>
61
61
  </tr>
62
62
  <tr>
63
63
  <td valign="top">
64
- <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.16" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
64
+ <a href="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pingcf-thoughts.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/pingcf-thoughts.png?v=0.9.18" alt="An expanded Thoughts block showing the agent's reasoning above its reply"></picture></a>
65
65
  <br><sub>Read the reasoning, not just the answer.</sub>
66
66
  </td>
67
67
  </tr>
68
68
  <tr>
69
69
  <td valign="top">
70
- <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.16" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
70
+ <a href="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-watching.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/pagewright-watching.png?v=0.9.18" alt="A session watching two other sessions: a finished reply arrives as a compact relay-event row, the watching 2 sessions list is open with Unsubscribe, and a request to also watch a session on a paired relay waits on an Allow / Deny card"></picture></a>
71
71
  <br><sub>Agents that watch agents: woken by events, approving nothing.</sub>
72
72
  </td>
73
73
  </tr>
@@ -102,9 +102,11 @@ The runtime (the *provider* in the UI) is chosen in **New Chat** and stays fixed
102
102
  | [**OpenAI (BYOK)**](docs/providers.md#openai-byok-and-openai-image) | Copilot's agent against OpenAI or a compatible endpoint | Your own API key |
103
103
  | [**OpenAI Image**](docs/providers.md#openai-byok-and-openai-image) | A chat whose replies are images | The same key |
104
104
  | [**Claude**](docs/providers.md#claude-agent-sdk) | Claude Code on your machine | A Claude login, made from **⚙️ Settings** |
105
+ | [**Claude API**](docs/providers.md#claude-api) | Claude Code on your machine, billed to a Claude Console API key; a Claude chat can switch between the two | A Claude Console API key |
105
106
  | [**Claude Cloud**](docs/providers.md#claude-cloud) (experimental) | Claude Code in a sandbox at Anthropic, on a GitHub repository | A claude.ai account with GitHub connected |
106
107
  | [**Cursor**](docs/providers.md#cursor-agent-sdk) | Cursor's agent | A Cursor API key |
107
108
  | [**Grok**](docs/providers.md#grok-cli-acp) | xAI's Grok CLI | A Grok sign-in, made from **⚙️ Settings** |
109
+ | [**DeepSeek**](docs/providers.md#deepseek) | Claude Code on your machine, thinking with DeepSeek's models | A DeepSeek API key |
108
110
 
109
111
  API keys are entered in **⚙️ Settings** and stay on the relay host. The Claude and Grok CLIs can be [installed and signed in to from the browser](docs/providers.md#installing-a-provider-cli-from-the-relay), so no shell on the host is needed. Each runtime discovers its own [models](docs/providers.md#models). The full picture of each runtime is in [Providers](docs/providers.md).
110
112
 
@@ -116,11 +118,11 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
116
118
  <table>
117
119
  <tr>
118
120
  <td width="62%" valign="top">
119
- <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.16" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
121
+ <a href="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/cfmail-steering.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/cfmail-steering.png?v=0.9.18" alt="A running Claude turn with a message steered into it mid-turn, the reply streaming with its tool calls, and the Steer button in the composer"></picture></a>
120
122
  <br><sub>Send a message while the agent works, and it steers the running turn.</sub>
121
123
  </td>
122
124
  <td width="38%" valign="top">
123
- <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.16" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
125
+ <a href="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/usage-copilot-compact.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/usage-copilot-compact.png?v=0.9.18" alt="The GitHub Copilot card in Check Usage: premium requests and plan quota with reset dates, and the cost of the last turn"></picture></a>
124
126
  <br><sub>What each plan has left, in one place.</sub>
125
127
  </td>
126
128
  </tr>
@@ -132,12 +134,12 @@ API keys are entered in **⚙️ Settings** and stay on the relay host. The Clau
132
134
  - **Background work**: a task panel lists the commands and sub-agents that outlive a reply, each with its own **Stop** and its full record; a command's console shows its output live, says when it has gone quiet and counts a test run's passes and failures; the panel and its consoles come back after a relay restart, and a queued restart shows what it waits for and asks after a maximum wait ([Background tasks](docs/web-ui.md#background-tasks), [After a relay restart](docs/web-ui.md#after-a-relay-restart)).
133
135
  - **Choose how the agent works**: `ask`, `plan`, `agent` or `autopilot`, plus model and reasoning effort, per message ([Relay modes](docs/web-ui.md#relay-modes)).
134
136
  - **Files and git**: browse the workspace, preview files, review git changes in a diff viewer, and publish a dev server as a preview link ([Files, git, and previews](docs/web-ui.md#files-git-and-previews), [preview servers](docs/preview-servers.md)).
135
- - **Usage and context**: plan limits and credits of every provider, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
137
+ - **Usage and context**: plan limits and credits of every provider, the estimated spend of DeepSeek and Claude API keys, and what fills the model's context window ([Usage and context](docs/web-ui.md#usage-and-context)).
136
138
  - **Your conversations everywhere**: the history lives on the relay, drafts follow you from device to device, and conversations can be searched, archived, grouped and pinned ([The conversation list](docs/web-ui.md#the-conversation-list), [drafts](docs/web-ui.md#drafts-across-devices), [session groups](docs/web-ui.md#session-groups), [pins](docs/web-ui.md#pinned-messages)).
137
139
  - **Share** a conversation as a read-only link, hide single messages and the host's paths from it, and see when it was opened ([Sharing](docs/web-ui.md#sharing)).
138
140
  - **On your phone**: install OAR as an app and get push notifications ([Notifications and the app](docs/web-ui.md#notifications-and-the-app)).
139
141
 
140
- The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.16](CHANGELOG.md#0916--2026-10-24) as the latest.
142
+ The full tour is in [Using the web UI](docs/web-ui.md); every setting and config key is in [Settings and configuration](docs/configuration.md). What changed in each release is in the [changelog](CHANGELOG.md), with [0.9.18](CHANGELOG.md#0918--2026-10-26) as the latest.
141
143
 
142
144
  ## Remote access
143
145
 
@@ -188,7 +190,7 @@ flowchart LR
188
190
 
189
191
  You stay in charge: per paired relay you decide whether agents may only read, also prompt, or do everything, and in the `ask` and `plan` modes every write asks you first. A subscription only notifies; it approves nothing.
190
192
 
191
- <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.16"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.16"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.16" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
193
+ <p><a href="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.18"><picture><source media="(prefers-color-scheme: light)" srcset="https://oar.sh/assets/shots/pagewright-cross-relay.light.png?v=0.9.18"><img src="https://oar.sh/assets/shots/pagewright-cross-relay.png?v=0.9.18" alt="A session watching two sessions on a paired relay: a reply arrives as a relay-event row, and after a short outage the events the other relay kept arrive as one replayed row" width="720"></picture></a></p>
192
194
 
193
195
  How pairing, permissions and limits work is in [Agents working together](docs/agents-together.md).
194
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.16",
3
+ "version": "0.9.18",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -49,6 +49,7 @@
49
49
  "setup:git": "node scripts/setup-git.mjs",
50
50
  "land": "node scripts/land.mjs",
51
51
  "prune:local": "node scripts/prune-local.mjs",
52
+ "deepseek:import": "node server/services/deepseek-usage-import.mjs",
52
53
  "test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
53
54
  },
54
55
  "dependencies": {
@@ -0,0 +1,345 @@
1
+ // The Claude API backend of the Claude session runner: Claude Code on the
2
+ // Anthropic API, billed to the API key saved in Settings → Providers → Claude
3
+ // API instead of the host's Claude login.
4
+ //
5
+ // - The CLI's environment: the key as ANTHROPIC_API_KEY, the Anthropic API as
6
+ // ANTHROPIC_BASE_URL, and nothing of the host's Claude login, another
7
+ // Anthropic configuration or another provider's switch. The host's Claude
8
+ // Code state directory stays (CLAUDE_CONFIG_DIR is left as the relay set
9
+ // it): the chats are Claude chats, with the Claude provider's sessions,
10
+ // transcripts, resume and task output.
11
+ // - The flag settings layer: the user's settings and the chat folder's
12
+ // (.claude/settings.json, .claude/settings.local.json) can set `env`
13
+ // (another ANTHROPIC_BASE_URL, an ANTHROPIC_API_KEY that replaces the key,
14
+ // an ANTHROPIC_AUTH_TOKEN that beats it, a proxy, another provider, custom
15
+ // headers, a CA) and `apiKeyHelper` (a script whose output beats it). The
16
+ // flag layer outranks them all, so it pins the base URL and the key, blanks
17
+ // the bearer token and the helper, and holds every other variable that
18
+ // routes the requests or decides whom the CLI trusts at the CLI's own value
19
+ // (CLAUDE_CLI_PINNED_ENV, shared with the DeepSeek backend). The spawn's
20
+ // `--settings` argument, which other users of the host can read, carries
21
+ // the endpoint and the blanks (claudeApiFlagSettings); the key and the
22
+ // worker's own proxy and TLS values go into the same layer over the CLI's
23
+ // stdin before the first message (liveFlagSettings, claude-sdk-adapter.mjs;
24
+ // scripts/claude-api-cli-proof.mjs shows it).
25
+ // - The check: the CLI's `system/init` names where it took its API key from.
26
+ // Anything but ANTHROPIC_API_KEY stops the turn as soon as the init is read
27
+ // (checkInit). The CLI does not wait for that verdict: it sends its first
28
+ // request some 50 to 90 ms after the init (scripts/claude-api-cli-proof.mjs),
29
+ // so one request may already be out on the other credential when the stop
30
+ // lands. The check limits a wrong credential to that one request; it does
31
+ // not prevent it.
32
+ // - The model, effort, thinking and images: Claude's, exactly as the Claude
33
+ // worker has them (`claudeModels`).
34
+ // - What it reports: no Claude plan usage (there is no plan) and no Claude
35
+ // usage limit (the API answers with its own errors, which describeFailure
36
+ // reads in plain words).
37
+ //
38
+ // - Hooks: none run, neither the user's nor the chat folder's, and no
39
+ // statusLine command (CLAUDE_CLI_KEY_CHAT_SETTINGS, on the command line).
40
+ //
41
+ // The agent's shell inherits the CLI's environment, the key included; the
42
+ // relay takes the saved key out of what the worker posts and logs.
43
+
44
+ import { resolveAnthropicApiBaseUrl } from '../../shared/anthropic-api.mjs';
45
+ import { isHostClaudeSessionVariable } from '../../shared/host-claude-session-env.mjs';
46
+ import { CLAUDE_CLI_KEY_CHAT_SETTINGS, claudeCliPinnedEnv } from '../claude-worker/claude-cli-pinned-env.mjs';
47
+
48
+ export const CLAUDE_API_BACKEND_ID = 'claude-api';
49
+ export const CLAUDE_API_BACKEND_LABEL = 'Claude API';
50
+ // Where the relay's launch puts the key (session-worker-launch-service.mjs).
51
+ export const CLAUDE_API_KEY_ENV = 'OAR_CLAUDE_API_KEY';
52
+ // The stable code of a turn stopped because the CLI took another credential.
53
+ export const CLAUDE_API_KEY_SOURCE_CODE = 'claude-api-key-source';
54
+ // The only API key source the check lets through: the key in the CLI's
55
+ // environment, which is this backend's.
56
+ export const EXPECTED_API_KEY_SOURCE = 'ANTHROPIC_API_KEY';
57
+ // The sources the SDK's `system/init` can name (sdk.d.ts, ApiKeySource). A
58
+ // note names one of these, never a text of the CLI's own.
59
+ const KNOWN_API_KEY_SOURCES = Object.freeze([
60
+ 'ANTHROPIC_API_KEY',
61
+ 'apiKeyHelper',
62
+ '/login managed key',
63
+ 'none',
64
+ 'user',
65
+ 'project',
66
+ 'org',
67
+ 'temporary',
68
+ 'oauth',
69
+ ]);
70
+
71
+ const BILLING_URL = 'https://platform.claude.com/settings/billing';
72
+ const LIMITS_URL = 'https://platform.claude.com/settings/limits';
73
+ const SETTINGS_PLACE = 'Settings → Providers → Claude API';
74
+ const KEY_SOURCE_GUIDANCE = 'Remove the apiKeyHelper or other credential from the host\'s Claude Code settings '
75
+ + `(settings.json), check the key in ${SETTINGS_PLACE}, then send the message again.`;
76
+
77
+ // Variables that would hand the CLI another credential or point it at another
78
+ // endpoint or cloud (the bundled CLI, 2026-10-25). Every ANTHROPIC_* is
79
+ // dropped as well (FOREIGN_CLI_PREFIXES).
80
+ const FOREIGN_CLI_VARIABLES = Object.freeze([
81
+ // The host's Claude login, and the other ways to hand the CLI a login or
82
+ // a key.
83
+ 'CLAUDE_CODE_OAUTH_TOKEN',
84
+ 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
85
+ 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN',
86
+ 'CLAUDE_CODE_OAUTH_SCOPES',
87
+ 'CLAUDE_CODE_OAUTH_CLIENT_ID',
88
+ 'CLAUDE_CODE_CUSTOM_OAUTH_URL',
89
+ 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
90
+ 'CLAUDE_CODE_API_KEY_HELPER_TTL_MS',
91
+ // Another provider for the CLI's requests, and their auth bypasses.
92
+ 'CLAUDE_CODE_USE_BEDROCK',
93
+ 'CLAUDE_CODE_USE_VERTEX',
94
+ 'CLAUDE_CODE_USE_FOUNDRY',
95
+ 'CLAUDE_CODE_USE_ANTHROPIC_AWS',
96
+ 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
97
+ 'CLAUDE_CODE_USE_GATEWAY',
98
+ 'CLAUDE_CODE_USE_MANTLE',
99
+ 'CLAUDE_CODE_USE_CCR_V2',
100
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
101
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
102
+ 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
103
+ 'CLAUDE_CODE_SKIP_MANTLE_AUTH',
104
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_AWS_AUTH',
105
+ 'CLAUDE_CODE_SKIP_ANTHROPIC_GOOGLE_CLOUD_AUTH',
106
+ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
107
+ // A host's model and effort pins: the turn's model and effort come from
108
+ // the relay, which bills them to the key.
109
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
110
+ 'CLAUDE_CODE_SUBAGENT_MODEL_FORCE',
111
+ 'CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL',
112
+ 'CLAUDE_CODE_EFFORT_LEVEL',
113
+ 'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
114
+ // Never set, and dropped when inherited: it forces the permission mode to
115
+ // default (plan mode is lost) and sandboxes Bash.
116
+ 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB',
117
+ // What the relay hands its worker (session-worker-launch-service.mjs,
118
+ // TMUX_WORKER_EXPORTED_ENV_VARS): the worker's own settings, which neither
119
+ // Claude Code nor anything the agent runs reads. A shell of the agent's
120
+ // that starts a relay of its own would take the worker's kind, folders and
121
+ // tools file for its own. The relay's Claude configuration
122
+ // (CLAUDE_CONFIG_DIR, CLAUDE_CODE_EXECUTABLE) is not on it.
123
+ 'COPILOT_ALLOW_ALL',
124
+ 'GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS',
125
+ 'COPILOT_SDK_PATH',
126
+ 'EXTENSION_PATH',
127
+ 'COPILOT_MODEL',
128
+ 'COPILOT_RELAY_MODEL',
129
+ 'CLAUDE_RELAY_MODEL',
130
+ 'CLAUDE_CLOUD_RELAY_MODEL',
131
+ 'CLAUDE_API_RELAY_MODEL',
132
+ 'CURSOR_RELAY_MODEL',
133
+ 'CURSOR_AGENT_STORE_DIR',
134
+ 'CURSOR_API_KEY',
135
+ 'GROK_RELAY_MODEL',
136
+ 'GROK_CLI_COMMAND',
137
+ 'GROK_ALWAYS_APPROVE',
138
+ 'SESSION_ID',
139
+ 'COPILOT_WORKSPACE_ROOT',
140
+ 'INIT_CWD',
141
+ ]);
142
+ // The same by prefix: every Anthropic variable (the two the CLI needs are set
143
+ // again below), the relay's folders, config, tools file, worker kind and log
144
+ // file (COPILOT_WEB_RELAY_*), the Copilot provider settings, the DeepSeek
145
+ // worker's settings, every Claude gateway setting and the relay's own OAR_*
146
+ // switches (the key's own variable among them).
147
+ const FOREIGN_CLI_PREFIXES = Object.freeze([
148
+ 'ANTHROPIC_',
149
+ 'COPILOT_WEB_RELAY_',
150
+ 'COPILOT_PROVIDER_',
151
+ 'DEEPSEEK_',
152
+ 'CLAUDE_GATEWAY_',
153
+ 'OAR_',
154
+ ]);
155
+
156
+ /**
157
+ * Whether a variable of the worker's stays out of the CLI's environment:
158
+ * another credential, endpoint or provider, the relay's settings for its
159
+ * worker, or the host Claude Code session's variables when the relay was
160
+ * started from inside one (shared/host-claude-session-env.mjs; the launcher
161
+ * drops those for every worker, the backend again, however the worker was
162
+ * started).
163
+ */
164
+ function isForeignCliVariable(key) {
165
+ const name = String(key).toUpperCase();
166
+ return FOREIGN_CLI_VARIABLES.includes(name)
167
+ || FOREIGN_CLI_PREFIXES.some((prefix) => name.startsWith(prefix))
168
+ || isHostClaudeSessionVariable(name);
169
+ }
170
+
171
+ function text(value) {
172
+ return String(value ?? '').trim();
173
+ }
174
+
175
+ /**
176
+ * The environment the CLI runs with: the worker's own without anything
177
+ * foreign, then the key and the endpoint. `OAR_ANTHROPIC_API_BASE_URL` names
178
+ * a fake API for tests; only a loopback URL is taken from it
179
+ * (resolveAnthropicApiBaseUrl), anything else is the Anthropic API.
180
+ */
181
+ export function claudeApiCliEnv(baseEnv = process.env, { apiKey = '', baseUrl = '' } = {}) {
182
+ const key = text(apiKey);
183
+ if (!key) throw new Error('claude-api-key-not-configured');
184
+ const next = {};
185
+ for (const [name, value] of Object.entries(baseEnv || {})) {
186
+ if (isForeignCliVariable(name)) continue;
187
+ next[name] = value;
188
+ }
189
+ next.ANTHROPIC_API_KEY = key;
190
+ next.ANTHROPIC_BASE_URL = text(baseUrl) || resolveAnthropicApiBaseUrl(baseEnv || {});
191
+ return next;
192
+ }
193
+
194
+ /**
195
+ * The flag settings layer every CLI of this backend is spawned with, on its
196
+ * command line: no key in it. It outranks the user's, the project's and the
197
+ * local settings, so their `env` cannot send the requests elsewhere or swap
198
+ * the key for a bearer token, and their `apiKeyHelper` cannot swap it for
199
+ * its own (nor run at the CLI's startup). No hooks and no statusLine command
200
+ * run (CLAUDE_CLI_KEY_CHAT_SETTINGS). Skills, plugins and MCP servers are
201
+ * left as the other layers have them.
202
+ */
203
+ export function claudeApiFlagSettings(baseUrl) {
204
+ return {
205
+ ...CLAUDE_CLI_KEY_CHAT_SETTINGS,
206
+ env: {
207
+ ANTHROPIC_BASE_URL: text(baseUrl),
208
+ ANTHROPIC_AUTH_TOKEN: '',
209
+ },
210
+ apiKeyHelper: '',
211
+ };
212
+ }
213
+
214
+ /**
215
+ * The same layer with the key, which the CLI takes over stdin before the
216
+ * first message (claude-sdk-adapter.mjs): a settings file's
217
+ * env.ANTHROPIC_API_KEY would otherwise replace the CLI's environment's.
218
+ * Every other variable that routes the CLI's requests, and with them the key,
219
+ * or decides whom it trusts on the way (the proxies, the other providers'
220
+ * switches and endpoints, the custom headers, the CA and TLS settings) holds
221
+ * the value of `cliEnv`, the environment the CLI was spawned with, or is
222
+ * blanked (claudeCliPinnedEnv).
223
+ */
224
+ export function claudeApiLiveFlagSettings(baseUrl, apiKey, cliEnv = {}) {
225
+ const key = text(apiKey);
226
+ if (!key) throw new Error('claude-api-key-not-configured');
227
+ const settings = claudeApiFlagSettings(baseUrl);
228
+ return { ...settings, env: { ...claudeCliPinnedEnv(cliEnv), ...settings.env, ANTHROPIC_API_KEY: key } };
229
+ }
230
+
231
+ /** The source the CLI named, as a note may quote it: a known name, or a stand-in. */
232
+ function quotedApiKeySource(value) {
233
+ const source = typeof value === 'string' ? value : '';
234
+ if (KNOWN_API_KEY_SOURCES.includes(source)) return `"${source}"`;
235
+ return source ? 'a source this relay does not know' : 'no source';
236
+ }
237
+
238
+ /**
239
+ * Null when the CLI's `system/init` says it took the key from
240
+ * ANTHROPIC_API_KEY (the backend's), else the Error that stops the turn. Its
241
+ * message names the source the CLI reported, never a key. `afterTraffic`: the
242
+ * init came while the turn was already talking to the API, so one request
243
+ * may have gone out on the other credential.
244
+ */
245
+ export function checkClaudeApiInit(sdkMessage, { afterTraffic = false } = {}) {
246
+ if (!sdkMessage || sdkMessage.type !== 'system' || sdkMessage.subtype !== 'init') return null;
247
+ const source = sdkMessage.apiKeySource;
248
+ if (source === EXPECTED_API_KEY_SOURCE) return null;
249
+ const error = new Error(
250
+ `Claude Code reported ${quotedApiKeySource(source)} as its API key source instead of the saved Claude API key, so the turn was stopped.`
251
+ + (afterTraffic ? ' At most one request may have gone out on that credential.' : ''),
252
+ );
253
+ error.code = CLAUDE_API_KEY_SOURCE_CODE;
254
+ error.apiKeySource = KNOWN_API_KEY_SOURCES.includes(source) ? source : null;
255
+ error.guidance = KEY_SOURCE_GUIDANCE;
256
+ return error;
257
+ }
258
+
259
+ /**
260
+ * `{ code, guidance }` for a failed request the Claude API has its own advice
261
+ * for, or null. Read from the error the runner stopped the turn with, the
262
+ * HTTP status the result reports, else from the status and the words the CLI
263
+ * quotes in its error text ("API Error: 401 …"). The account refusals come
264
+ * first: a spend limit can come as a 429, which is not a rate limit.
265
+ */
266
+ export function describeClaudeApiFailure(errorText, { status = null, error = null } = {}) {
267
+ if (error?.code === CLAUDE_API_KEY_SOURCE_CODE) {
268
+ return { code: CLAUDE_API_KEY_SOURCE_CODE, guidance: error.guidance || KEY_SOURCE_GUIDANCE };
269
+ }
270
+ const message = text(errorText);
271
+ const code = Number(status);
272
+ if (/credit balance is too low/i.test(message)) {
273
+ return {
274
+ code: 'claude-api-credit',
275
+ guidance: `The Claude API account's credit balance is too low. Add credits in the Claude Console (${BILLING_URL}), then send the message again.`,
276
+ };
277
+ }
278
+ if (/API usage limits|enforced_spend_limit_reached/i.test(message)) {
279
+ return {
280
+ code: 'claude-api-spend-limit',
281
+ guidance: `The Claude API account has reached its spend limit. Raise the limit in the Claude Console (${LIMITS_URL}) or wait until it resets, then send the message again.`,
282
+ };
283
+ }
284
+ if (code === 402 || /\b402\b|billing_error/i.test(message)) {
285
+ return {
286
+ code: 'claude-api-billing',
287
+ guidance: `The Claude API refused the request for a billing reason. Check the payment details in the Claude Console (${BILLING_URL}), then send the message again.`,
288
+ };
289
+ }
290
+ if (
291
+ code === 401
292
+ || code === 403
293
+ || /\b40[13]\b|authentication_error|permission_error|invalid x-api-key|invalid api key/i.test(message)
294
+ ) {
295
+ return {
296
+ code: 'claude-api-key',
297
+ guidance: `The Claude API did not accept the API key. Check it in ${SETTINGS_PLACE}, then send the message again.`,
298
+ };
299
+ }
300
+ if (code === 429 || code === 529 || /\b(?:429|529)\b|rate_limit_error|overloaded_error/i.test(message)) {
301
+ return {
302
+ code: 'claude-api-rate-limit',
303
+ guidance: `The Claude API is rate-limiting this key or is overloaded. Send the message again in a moment; the key's rate limits are in the Claude Console (${LIMITS_URL}).`,
304
+ };
305
+ }
306
+ return null;
307
+ }
308
+
309
+ export function createClaudeApiClaudeBackend({ env = process.env } = {}) {
310
+ const apiKey = text(env[CLAUDE_API_KEY_ENV]);
311
+ const baseUrl = resolveAnthropicApiBaseUrl(env);
312
+
313
+ return {
314
+ id: CLAUDE_API_BACKEND_ID,
315
+ providerType: CLAUDE_API_BACKEND_ID,
316
+ label: CLAUDE_API_BACKEND_LABEL,
317
+ // Claude's models, effort, thinking, `[1m]`, images and sub-agent models,
318
+ // exactly as the Claude worker has them: the runner and the adapter take
319
+ // none of those from this backend.
320
+ claudeModels: true,
321
+ // No plan windows and no subscription usage limit on an API key, and no
322
+ // usage route of its own.
323
+ reportsPlanUsage: false,
324
+ reportsUsageLimit: false,
325
+ usageRoute: null,
326
+ describeFailure: describeClaudeApiFailure,
327
+ checkInit: checkClaudeApiInit,
328
+ /** The settings layer pin, merged into the spawn's `settings` (no key in it). */
329
+ flagSettings: () => claudeApiFlagSettings(baseUrl),
330
+ /**
331
+ * The layer again with the key and the routing and trust pins for a CLI
332
+ * spawned with `cliEnv`, over stdin before the first message.
333
+ */
334
+ liveFlagSettings: (cliEnv = claudeApiCliEnv(env, { apiKey, baseUrl })) => claudeApiLiveFlagSettings(baseUrl, apiKey, cliEnv),
335
+
336
+ /**
337
+ * The CLI's environment, built from the worker's own (which must not
338
+ * reach the CLI as it is: it carries OAR_CLAUDE_API_KEY and possibly the
339
+ * host's login or Anthropic settings).
340
+ */
341
+ spawnEnv(baseEnv = process.env) {
342
+ return claudeApiCliEnv(baseEnv, { apiKey, baseUrl });
343
+ },
344
+ };
345
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // Claude API session worker: the Claude session worker with its CLI on the
3
+ // Anthropic API and the key saved in Settings → Providers → Claude API
4
+ // (claude-api-backend.mjs). The relay's launch puts the key in
5
+ // OAR_CLAUDE_API_KEY (through the owner-only secret file under tmux) and the
6
+ // chat's model in CLAUDE_API_RELAY_MODEL. The CLI keeps the host's Claude
7
+ // Code state directory: the chats are Claude chats, resumed and read where
8
+ // the Claude worker reads them.
9
+ import { installWorkerLogFile, installWorkerLogRedaction } from '../../shared/worker-runtime/worker-log-file.mjs';
10
+ import { runClaudeSessionWorker } from '../claude-worker/claude-session-worker-main.mjs';
11
+ import { CLAUDE_API_KEY_ENV, createClaudeApiClaudeBackend } from './claude-api-backend.mjs';
12
+
13
+ installWorkerLogFile();
14
+ // The key never shows in this worker's console or log, whatever the CLI or
15
+ // the agent prints (installed after the log file, so its copy is redacted too).
16
+ installWorkerLogRedaction({ secrets: [process.env[CLAUDE_API_KEY_ENV]] });
17
+
18
+ const backend = createClaudeApiClaudeBackend({ env: process.env });
19
+
20
+ runClaudeSessionWorker({
21
+ logTag: 'claude-api-worker',
22
+ workerName: 'claude-api-session-worker',
23
+ // None set: the CLI's own default, as for a Claude chat without a model.
24
+ defaultModel: String(process.env.CLAUDE_API_RELAY_MODEL || '').trim(),
25
+ backend,
26
+ }).catch((error) => {
27
+ console.error('claude-api-session-worker fatal:', error?.stack || error?.message || error);
28
+ process.exit(1);
29
+ });
@@ -52,6 +52,9 @@ function imageBlockFromDataUrl(att, maxInlineImageBytes) {
52
52
  export function buildClaudeAttachmentContent(rawAttachments, {
53
53
  fsImpl = fs,
54
54
  maxInlineImageBytes = MAX_INLINE_IMAGE_BYTES,
55
+ // False for a model that takes no image input: an image block would fail
56
+ // the whole request, so every image goes by its path instead.
57
+ embedImages = true,
55
58
  } = {}) {
56
59
  const input = Array.isArray(rawAttachments) ? rawAttachments : [];
57
60
  const imageBlocks = [];
@@ -67,7 +70,7 @@ export function buildClaudeAttachmentContent(rawAttachments, {
67
70
  // Only media types the Anthropic image block accepts are embedded;
68
71
  // everything else (svg, bmp, heic, …) would 400 the whole turn and
69
72
  // falls back to a path reference instead.
70
- if (filePath && isEmbeddableImageMediaType(mime) && fsImpl.existsSync(filePath)) {
73
+ if (embedImages && filePath && isEmbeddableImageMediaType(mime) && fsImpl.existsSync(filePath)) {
71
74
  try {
72
75
  const bytes = fsImpl.readFileSync(filePath);
73
76
  if (Buffer.isBuffer(bytes) && bytes.length && bytes.length <= maxInlineImageBytes) {
@@ -85,7 +88,7 @@ export function buildClaudeAttachmentContent(rawAttachments, {
85
88
  embedded = false;
86
89
  }
87
90
  }
88
- if (!embedded) {
91
+ if (!embedded && embedImages) {
89
92
  const blockFromDataUrl = imageBlockFromDataUrl(att, maxInlineImageBytes);
90
93
  if (blockFromDataUrl) {
91
94
  imageBlocks.push(blockFromDataUrl);
@@ -118,9 +121,9 @@ export function buildClaudeAttachmentContent(rawAttachments, {
118
121
  * Text first (user text + attachment notes + server-provided attachment
119
122
  * context), then inline image blocks.
120
123
  */
121
- export function buildClaudeUserContent(message, { fsImpl = fs, maxInlineImageBytes = MAX_INLINE_IMAGE_BYTES } = {}) {
124
+ export function buildClaudeUserContent(message, { fsImpl = fs, maxInlineImageBytes = MAX_INLINE_IMAGE_BYTES, embedImages = true } = {}) {
122
125
  const text = String(message?.text || '').trim();
123
- const { imageBlocks, noteLines } = buildClaudeAttachmentContent(message?.attachments, { fsImpl, maxInlineImageBytes });
126
+ const { imageBlocks, noteLines } = buildClaudeAttachmentContent(message?.attachments, { fsImpl, maxInlineImageBytes, embedImages });
124
127
  const attachmentPromptContext = String(message?.attachmentPromptContext || '').trim();
125
128
  const textParts = [text];
126
129
  if (noteLines.length) textParts.push(noteLines.join('\n'));