@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,343 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The Claude billing switch: a Claude chat moves between the host's Claude
5
+ * subscription (`claude`) and the API key saved in Settings → Providers →
6
+ * Claude API (`claude-api`), and keeps its Claude Code session: both run the
7
+ * same CLI on the same `~/.claude`.
8
+ *
9
+ * The chat's billing is `runtime_sessions.provider_type`. A switch, in one
10
+ * transaction:
11
+ * - checks the target: the provider usable (a key for the API, the Claude
12
+ * provider on for the subscription), the monthly cap, the chat's model in
13
+ * the target's list, and the history rule of decision 23;
14
+ * - writes the billing, when and by whom;
15
+ * - re-stamps the chat's rows that have not started (`queue.provider`), so
16
+ * they run on the new billing; a row already running finishes on its own;
17
+ * - to the API key, releases a turn held at the subscription's usage limit;
18
+ * - leaves one note in the transcript (a second switch before the next turn
19
+ * rewrites it);
20
+ * - and, for an agent's `send`, enqueues its message (`enqueue`).
21
+ *
22
+ * The worker swap itself is the delivery's (claude-billing-swap-service.mjs):
23
+ * the next row of the new billing is not handed to a worker of the old kind,
24
+ * which is replaced once it is idle.
25
+ */
26
+
27
+ import { randomUUID } from 'node:crypto';
28
+
29
+ import { claudeBaseModelId, isClaudeLongContextModelId } from '../../shared/model-id.mjs';
30
+
31
+ export const CLAUDE_BILLING_PROVIDERS = Object.freeze(['claude', 'claude-api']);
32
+ export const CLAUDE_BILLING_NOTE_KIND = 'billing-switch';
33
+ export const CLAUDE_BILLING_SWITCHED_BY = Object.freeze(['user', 'agent', 'usage-limit']);
34
+
35
+ // Decision 23: whether a chat with turns on the other billing may move to
36
+ // this one. Set from the WP3 live check (a subscription session's history on
37
+ // the key, and back); 'blocked' disables the switch for such chats.
38
+ export const CLAUDE_BILLING_HISTORY_RULE = Object.freeze({
39
+ 'claude-api': 'allowed',
40
+ claude: 'allowed',
41
+ });
42
+
43
+ export const CLAUDE_BILLING_LABELS = Object.freeze({
44
+ claude: 'Subscription',
45
+ 'claude-api': 'API key',
46
+ });
47
+
48
+ export function normalizeClaudeBilling(value) {
49
+ const billing = String(value || '').trim().toLowerCase();
50
+ return CLAUDE_BILLING_PROVIDERS.includes(billing) ? billing : '';
51
+ }
52
+
53
+ export function otherClaudeBilling(billing) {
54
+ return normalizeClaudeBilling(billing) === 'claude' ? 'claude-api' : 'claude';
55
+ }
56
+
57
+ /** The transcript note for a switch. */
58
+ export function buildClaudeBillingNoteText({ to, by = 'user' } = {}) {
59
+ const who = by === 'agent' ? ' (switched by an agent)' : (by === 'usage-limit' ? ' (switched at the usage limit)' : '');
60
+ if (normalizeClaudeBilling(to) === 'claude-api') {
61
+ return `💳 Billing: this chat runs on the Claude API key from the next turn${who}. `
62
+ + 'The first turn on the key has no prompt cache and costs more.';
63
+ }
64
+ return `💳 Billing: this chat runs on the Claude subscription from the next turn${who}.`;
65
+ }
66
+
67
+ function refusal(status, code, error, extra = {}) {
68
+ return { ok: false, status, code, error, ...extra };
69
+ }
70
+
71
+ function modelListFor(settings) {
72
+ return new Set([
73
+ String(settings?.model || '').trim(),
74
+ ...(Array.isArray(settings?.availableModels) ? settings.availableModels : []),
75
+ ...(Array.isArray(settings?.models) ? settings.models : []),
76
+ ].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean));
77
+ }
78
+
79
+ export function createClaudeBillingSwitchService({
80
+ db,
81
+ stmts,
82
+ emit = () => {},
83
+ getClaudeProviderSettings = () => null,
84
+ getClaudeApiProviderSettings = () => null,
85
+ // The monthly cap: `{ reached, error? }`, from the runtime's
86
+ // getClaudeApiCapState seam (the claim check asks the same one; WP5 fills
87
+ // it in). Not reached by default.
88
+ getClaudeApiCapState = () => ({ reached: false }),
89
+ historyRule = CLAUDE_BILLING_HISTORY_RULE,
90
+ // Releases a turn held at the subscription's usage limit
91
+ // (usage-limit-pause-service.mjs); runs inside the switch's transaction.
92
+ releaseUsageLimitPause = null,
93
+ // The runtime row → what the worker swap waits for (claude-billing-swap-
94
+ // service.mjs, pendingFor), or null.
95
+ getPending = () => null,
96
+ onQueueChanged = () => {},
97
+ now = () => Date.now(),
98
+ logger = console,
99
+ } = {}) {
100
+ if (!db || !stmts) throw new Error('createClaudeBillingSwitchService requires db and stmts');
101
+
102
+ const runtimeColumns = new Set(db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => column.name));
103
+ const queueColumns = new Set(db.prepare(`PRAGMA table_info(queue)`).all().map((column) => column.name));
104
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
105
+ const recordsSwitch = runtimeColumns.has('billing_switched_at') && runtimeColumns.has('billing_switched_by');
106
+
107
+ const getRuntime = db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ? LIMIT 1`);
108
+ const setBilling = recordsSwitch
109
+ ? db.prepare(`
110
+ UPDATE runtime_sessions
111
+ SET provider_type = ?, billing_switched_at = ?, billing_switched_by = ?, last_used_at = ?
112
+ WHERE id = ?
113
+ `)
114
+ : db.prepare(`UPDATE runtime_sessions SET provider_type = ?, last_used_at = ? WHERE id = ?`);
115
+ const setModel = db.prepare(`UPDATE runtime_sessions SET provider_model = ?, model = ? WHERE id = ?`);
116
+ const restampRows = queueColumns.has('provider')
117
+ ? db.prepare(`UPDATE queue SET provider = ? WHERE conversation_id = ? AND status IN ('pending', 'parked')`)
118
+ : null;
119
+ const countUnstartedRows = db.prepare(`
120
+ SELECT COUNT(*) AS n FROM queue WHERE conversation_id = ? AND status IN ('pending', 'parked')
121
+ `);
122
+ // The turns that ran on a billing other than `?`: replies with a recorded
123
+ // provider. A reply from before it was recorded (or a relay note) names
124
+ // none and does not count: an old chat is judged by the turns it has run
125
+ // since, and a history the API still refuses meets the turn's own refusal.
126
+ const countOtherBillingTurns = messageColumns.has('executed_provider')
127
+ ? db.prepare(`
128
+ SELECT COUNT(*) AS n FROM messages
129
+ WHERE conversation_id = ? AND role = 'assistant'
130
+ AND executed_provider IS NOT NULL AND LOWER(executed_provider) != ?
131
+ `)
132
+ : null;
133
+ const latestMessage = messageColumns.has('kind')
134
+ ? db.prepare(`SELECT id, role, kind, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC, rowid DESC LIMIT 1`)
135
+ : null;
136
+ const rewriteNote = db.prepare(`UPDATE messages SET text = ?, timestamp = ? WHERE id = ?`);
137
+
138
+ function readSettings(read) {
139
+ try {
140
+ const settings = read();
141
+ return settings && typeof settings === 'object' ? settings : {};
142
+ } catch {
143
+ return {};
144
+ }
145
+ }
146
+
147
+ function chatModel(runtime) {
148
+ return String(runtime?.provider_model || runtime?.model || '').trim();
149
+ }
150
+
151
+ /**
152
+ * Whether the chat may move to `to` now: `{ ok: true, runtime, from, to,
153
+ * model }`, or a refusal `{ ok: false, status, code, error }`. `model` is a
154
+ * model asked for with the switch, checked in place of the chat's own.
155
+ */
156
+ function checkSwitch({ conversationId, to, model = '' } = {}) {
157
+ const convId = String(conversationId || '').trim();
158
+ const target = normalizeClaudeBilling(to);
159
+ if (!target) return refusal(400, 'CLAUDE_BILLING_INVALID', 'Billing must be "claude" (subscription) or "claude-api" (API key)');
160
+ const runtime = convId ? (getRuntime.get(convId) || null) : null;
161
+ if (!runtime) return refusal(404, 'CONVERSATION_NOT_FOUND', 'Conversation not found');
162
+ const from = normalizeClaudeBilling(runtime.provider_type);
163
+ if (!from) {
164
+ return refusal(409, 'CLAUDE_BILLING_NOT_CLAUDE', 'Only a Claude chat can switch between the subscription and the API key', {
165
+ provider: String(runtime.provider_type || '').trim().toLowerCase() || 'github',
166
+ });
167
+ }
168
+ if (from === target) return { ok: true, runtime, from, to: target, model: chatModel(runtime), changed: false };
169
+ const settings = target === 'claude-api'
170
+ ? readSettings(getClaudeApiProviderSettings)
171
+ : readSettings(getClaudeProviderSettings);
172
+ if (target === 'claude-api' && settings.enabled !== true) {
173
+ return refusal(409, 'CLAUDE_API_NOT_CONFIGURED', settings.configured === true
174
+ ? 'Claude API is switched off. Switch it on in Settings → Providers → Claude API.'
175
+ : 'Claude API has no key. Save one in Settings → Providers → Claude API.');
176
+ }
177
+ if (target === 'claude' && settings.enabled !== true) {
178
+ return refusal(409, 'CLAUDE_NOT_CONFIGURED', 'The Claude provider is switched off. Switch it on in Settings → Providers → Claude.');
179
+ }
180
+ if (target === 'claude-api') {
181
+ let cap = null;
182
+ // A cap that cannot be read is taken as reached: the key's bill is at stake.
183
+ try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
184
+ if (cap?.reached === true) {
185
+ return refusal(409, 'CLAUDE_API_CAP_REACHED', String(cap.error || '').trim()
186
+ || "This month's Claude API spend has reached the cap set in Settings → Providers → Claude API.");
187
+ }
188
+ }
189
+ const wanted = String(model || '').trim() || chatModel(runtime);
190
+ if (wanted) {
191
+ const list = modelListFor(settings);
192
+ const exact = list.has(wanted.toLowerCase());
193
+ // A `[1m]` model needs its long-context variant listed; a plain id is
194
+ // also taken by its base.
195
+ const listed = exact || (!isClaudeLongContextModelId(wanted) && list.has(claudeBaseModelId(wanted).toLowerCase()));
196
+ if (!listed) {
197
+ return refusal(409, 'CLAUDE_BILLING_MODEL_UNAVAILABLE',
198
+ `${wanted} is not in the ${target === 'claude-api' ? 'Claude API' : 'Claude'} model list. Pick a model it has, then switch.`,
199
+ { model: wanted });
200
+ }
201
+ }
202
+ if (String(historyRule?.[target] || 'allowed') === 'blocked' && countOtherBillingTurns) {
203
+ const turns = Number(countOtherBillingTurns.get(runtime.conversation_id, target)?.n || 0);
204
+ if (turns > 0) {
205
+ return refusal(409, 'CLAUDE_BILLING_HISTORY', target === 'claude-api'
206
+ ? 'This chat already has subscription turns, and the API refuses that history. Start a new Claude API chat.'
207
+ : 'This chat already has API key turns, and the subscription refuses that history. Start a new Claude chat.');
208
+ }
209
+ }
210
+ return { ok: true, runtime, from, to: target, model: wanted, changed: true };
211
+ }
212
+
213
+ /** What the composer's toggle and the usage-limit note read about a chat's billing. */
214
+ function billingState(conversationId) {
215
+ const runtime = getRuntime.get(String(conversationId || '').trim()) || null;
216
+ const provider = normalizeClaudeBilling(runtime?.provider_type);
217
+ if (!provider) return null;
218
+ const to = otherClaudeBilling(provider);
219
+ const check = checkSwitch({ conversationId, to });
220
+ let pending = null;
221
+ try { pending = getPending(runtime) || null; } catch { pending = null; }
222
+ return {
223
+ provider,
224
+ pending,
225
+ switchedAt: runtime?.billing_switched_at || null,
226
+ switchedBy: runtime?.billing_switched_by || null,
227
+ switchable: {
228
+ to,
229
+ allowed: check.ok === true,
230
+ code: check.ok ? null : check.code,
231
+ reason: check.ok ? null : check.error,
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Switch the chat's billing. `enqueue({ runtime, billing })` runs inside
238
+ * the transaction after the switch (an agent's message), also when the
239
+ * chat already has that billing; a throw undoes the switch. A switch to
240
+ * the key releases a turn held at the subscription's usage limit.
241
+ */
242
+ function switchBilling({ conversationId, to, by = 'user', model = '', enqueue = null, deferAnnounce = false } = {}) {
243
+ const who = CLAUDE_BILLING_SWITCHED_BY.includes(by) ? by : 'user';
244
+ let outcome = null;
245
+ let note = null;
246
+ let released = null;
247
+ let enqueued = null;
248
+ const run = db.transaction(() => {
249
+ const check = checkSwitch({ conversationId, to, model });
250
+ if (!check.ok) {
251
+ outcome = check;
252
+ return;
253
+ }
254
+ const at = new Date(now()).toISOString();
255
+ const { runtime } = check;
256
+ if (check.changed) {
257
+ if (recordsSwitch) setBilling.run(check.to, at, who, at, runtime.id);
258
+ else setBilling.run(check.to, at, runtime.id);
259
+ if (String(model || '').trim() && check.model !== chatModel(runtime)) setModel.run(check.model, check.model, runtime.id);
260
+ restampRows?.run(check.to, runtime.conversation_id);
261
+ note = writeNote(runtime.conversation_id, check.to, who, at);
262
+ if (check.to === 'claude-api' && typeof releaseUsageLimitPause === 'function') {
263
+ released = releaseUsageLimitPause({ conversationId: runtime.conversation_id, by: 'billing-switch' }) || null;
264
+ }
265
+ }
266
+ if (typeof enqueue === 'function') enqueued = enqueue({ runtime, billing: check.to }) ?? null;
267
+ outcome = {
268
+ ok: true,
269
+ changed: check.changed,
270
+ conversationId: runtime.conversation_id,
271
+ provider: check.to,
272
+ from: check.from,
273
+ at,
274
+ queuedRows: Number(countUnstartedRows.get(runtime.conversation_id)?.n || 0),
275
+ };
276
+ });
277
+ run();
278
+ if (!outcome?.ok) return outcome;
279
+ const result = {
280
+ ...outcome,
281
+ noteMessageId: note?.messageId || null,
282
+ released: released ? { messageId: released.messageId || null } : null,
283
+ enqueued,
284
+ };
285
+ // Called inside a caller's transaction (the send route's persist step),
286
+ // the switch is not committed yet: the caller announces it once it is.
287
+ if (deferAnnounce) return { ...result, announce: () => announce(outcome, note, released, who) };
288
+ announce(outcome, note, released, who);
289
+ return result;
290
+ }
291
+
292
+ // What the clients and the queue hear of a committed switch.
293
+ function announce(outcome, note, released, who) {
294
+ try { released?.announce?.(); } catch {}
295
+ if (note) {
296
+ try {
297
+ emit(note.created ? 'assistant_message' : 'message_updated', {
298
+ conversationId: outcome.conversationId,
299
+ messageId: note.messageId,
300
+ message: {
301
+ id: note.messageId,
302
+ role: 'assistant',
303
+ text: note.text,
304
+ kind: CLAUDE_BILLING_NOTE_KIND,
305
+ model: null,
306
+ mode: null,
307
+ attachments: [],
308
+ timestamp: note.timestamp,
309
+ },
310
+ });
311
+ } catch {}
312
+ }
313
+ if (outcome.changed) {
314
+ try {
315
+ logger.log?.(`[claude-billing] conv=${outcome.conversationId.slice(0, 8)} ${outcome.from} → ${outcome.provider} by ${who}; ${outcome.queuedRows} queued row(s) re-stamped`);
316
+ } catch {}
317
+ try { emit('conversation_billing', { conversationId: outcome.conversationId, billing: billingState(outcome.conversationId) }); } catch {}
318
+ try { onQueueChanged('claude-billing-switch'); } catch {}
319
+ }
320
+ }
321
+
322
+ // One note per run of switches: one written since the chat's last message
323
+ // is rewritten in place, so a switch there and back leaves one line.
324
+ function writeNote(conversationId, to, by, at) {
325
+ const text = buildClaudeBillingNoteText({ to, by });
326
+ const latest = latestMessage?.get(conversationId) || null;
327
+ if (latest && latest.role === 'assistant' && latest.kind === CLAUDE_BILLING_NOTE_KIND) {
328
+ rewriteNote.run(text, at, latest.id);
329
+ return { messageId: latest.id, text, timestamp: at, created: false };
330
+ }
331
+ const messageId = randomUUID();
332
+ stmts.insertMsg.run(messageId, conversationId, 'assistant', text, null, null, null, at, null, null, null);
333
+ stmts.setMessageKind?.run?.(CLAUDE_BILLING_NOTE_KIND, messageId);
334
+ stmts.updateConvTime?.run?.(at, conversationId);
335
+ return { messageId, text, timestamp: at, created: true };
336
+ }
337
+
338
+ return {
339
+ checkSwitch,
340
+ billingState,
341
+ switchBilling,
342
+ };
343
+ }
@@ -0,0 +1,226 @@
1
+ // What a Claude Code session leaves in its config directory besides the
2
+ // transcript, removed when the relay deletes the conversation.
3
+ //
4
+ // Only for a config directory the relay owns (DeepSeek's `deepseek-claude`
5
+ // in the data folder), never the host's ~/.claude: the user's own CLI keeps
6
+ // its sessions there. The relay stops the worker before it deletes anything,
7
+ // and a stopped CLI is killed without its exit cleanup, so it leaves (the
8
+ // bundled CLI, checked 2026-10-25):
9
+ // - `session-env/<session id>/`, the session's hook environment files;
10
+ // - `sessions/<pid>.json`, the process's registration (`{ pid, sessionId,
11
+ // cwd, startedAt, ... }`), and `sessions/<pid>.<sha256>.key`, its
12
+ // messaging key;
13
+ // - `shell-snapshots/snapshot-<shell>-<ms>-<random>.sh`, the shell snapshot
14
+ // the process took at its start, whose name carries no session id: it is
15
+ // matched by the time in its name against the session's CLI starts;
16
+ // - `projects/<slug(cwd)>/` with an empty `memory/`, once its last session
17
+ // is gone.
18
+ // What belongs to another session stays: a registration of a live process,
19
+ // a snapshot that also lies in the start window of another session's CLI,
20
+ // and a project folder that still holds anything but an empty `memory/`.
21
+ //
22
+ // A CLI that exits on its own removes its registration but not always its
23
+ // snapshot: the registration goes in a synchronous `exit` handler, the
24
+ // snapshot only in the CLI's asynchronous cleanup, which a stop does not
25
+ // wait for (a worker restart left the first CLI's snapshot behind with no
26
+ // registration to match it). So the relay records every CLI start of a
27
+ // session itself (recordClaudeCliStart, from the worker's report at the
28
+ // CLI's first init) in `oar-cli-starts.json` in the config dir, and the
29
+ // delete matches snapshots against those starts as well as against the
30
+ // registrations. Every recorded start of another session protects its
31
+ // window, as a registration does.
32
+
33
+ import nodeFs from 'node:fs';
34
+ import nodePath from 'node:path';
35
+
36
+ const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
37
+ const REGISTRATION_PATTERN = /^(\d{1,10})\.json$/;
38
+ const SNAPSHOT_PATTERN = /^snapshot-[a-z]+-(\d{10,16})-[A-Za-z0-9]+(?:-[A-Za-z0-9-]+)?\.sh$/;
39
+ // The snapshot is taken while the CLI starts, around its registration.
40
+ const SNAPSHOT_WINDOW_BEFORE_MS = 60_000;
41
+ const SNAPSHOT_WINDOW_AFTER_MS = 120_000;
42
+ const MAX_ENTRIES = 2_000;
43
+ // The relay's record of CLI starts: one file, keyed by session id inside it.
44
+ const CLI_STARTS_FILE = 'oar-cli-starts.json';
45
+ const MAX_STARTS_PER_SESSION = 200;
46
+ const MAX_SESSIONS_RECORDED = 5_000;
47
+
48
+ function defaultIsPidAlive(pid) {
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ } catch (error) {
53
+ return error?.code === 'EPERM';
54
+ }
55
+ }
56
+
57
+ function readDir(fs, dir) {
58
+ try {
59
+ return fs.readdirSync(dir).slice(0, MAX_ENTRIES);
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ function remove(fs, target, removed) {
66
+ try {
67
+ fs.rmSync(target, { recursive: true, force: true });
68
+ removed.push(target);
69
+ } catch {}
70
+ }
71
+
72
+ /** The registrations in `sessions/`: `{ pid, sessionId, startedAt, file }`. */
73
+ function readRegistrations(fs, path, sessionsDir) {
74
+ const registrations = [];
75
+ for (const name of readDir(fs, sessionsDir)) {
76
+ const match = REGISTRATION_PATTERN.exec(name);
77
+ if (!match) continue;
78
+ let parsed = null;
79
+ try {
80
+ parsed = JSON.parse(fs.readFileSync(path.join(sessionsDir, name), 'utf8'));
81
+ } catch {
82
+ continue;
83
+ }
84
+ const startedAt = Number(parsed?.startedAt);
85
+ registrations.push({
86
+ pid: Number(match[1]),
87
+ sessionId: String(parsed?.sessionId ?? ''),
88
+ startedAt: Number.isFinite(startedAt) ? startedAt : null,
89
+ file: name,
90
+ });
91
+ }
92
+ return registrations;
93
+ }
94
+
95
+ /** `{ [sessionId]: [startedAt ms, ...] }` from the relay's record, or `{}`. */
96
+ function readCliStarts(fs, file) {
97
+ try {
98
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
99
+ const sessions = parsed?.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {};
100
+ const starts = {};
101
+ for (const [id, list] of Object.entries(sessions)) {
102
+ if (!SESSION_ID_PATTERN.test(id) || !Array.isArray(list)) continue;
103
+ starts[id] = list.map(Number).filter(Number.isFinite);
104
+ }
105
+ return starts;
106
+ } catch {
107
+ return {};
108
+ }
109
+ }
110
+
111
+ function writeCliStarts(fs, file, starts) {
112
+ const tmp = `${file}.${process.pid}.tmp`;
113
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, sessions: starts }), { encoding: 'utf8', mode: 0o600 });
114
+ fs.renameSync(tmp, file);
115
+ }
116
+
117
+ /**
118
+ * Note that a CLI started for `nativeSessionId` at `startedAt` (ms), under
119
+ * the relay's own config dir. Best-effort; returns whether it was noted.
120
+ */
121
+ export function recordClaudeCliStart({ configRoot, nativeSessionId, startedAt, fs = nodeFs, path = nodePath } = {}) {
122
+ const root = String(configRoot || '').trim();
123
+ const sessionId = String(nativeSessionId || '').trim();
124
+ const at = Number(startedAt);
125
+ if (!root || !SESSION_ID_PATTERN.test(sessionId) || !Number.isFinite(at) || at <= 0) return false;
126
+ try {
127
+ const resolvedRoot = path.resolve(root);
128
+ fs.mkdirSync(resolvedRoot, { recursive: true });
129
+ const file = path.join(resolvedRoot, CLI_STARTS_FILE);
130
+ const starts = readCliStarts(fs, file);
131
+ const list = starts[sessionId] || [];
132
+ if (!list.includes(at)) list.push(at);
133
+ starts[sessionId] = list.slice(-MAX_STARTS_PER_SESSION);
134
+ // A record that outgrows its cap drops the sessions started longest ago.
135
+ const ids = Object.keys(starts);
136
+ if (ids.length > MAX_SESSIONS_RECORDED) {
137
+ const newest = (id) => Math.max(...starts[id], 0);
138
+ for (const id of ids.sort((a, b) => newest(a) - newest(b)).slice(0, ids.length - MAX_SESSIONS_RECORDED)) delete starts[id];
139
+ }
140
+ writeCliStarts(fs, file, starts);
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ function inStartWindow(time, startedAt) {
148
+ return startedAt !== null && time >= startedAt - SNAPSHOT_WINDOW_BEFORE_MS && time <= startedAt + SNAPSHOT_WINDOW_AFTER_MS;
149
+ }
150
+
151
+ /**
152
+ * Remove what the session `nativeSessionId` left under `configRoot`, and
153
+ * `projectDirPath` when nothing but an empty `memory/` is left in it.
154
+ * Returns the paths removed. Best-effort: a path that will not go stays.
155
+ */
156
+ export function removeClaudeSessionLeftovers({
157
+ configRoot,
158
+ nativeSessionId,
159
+ projectDirPath = '',
160
+ fs = nodeFs,
161
+ path = nodePath,
162
+ isPidAlive = defaultIsPidAlive,
163
+ } = {}) {
164
+ const removed = [];
165
+ const root = String(configRoot || '').trim();
166
+ const sessionId = String(nativeSessionId || '').trim();
167
+ if (!root || !SESSION_ID_PATTERN.test(sessionId)) return removed;
168
+ const resolvedRoot = path.resolve(root);
169
+
170
+ const sessionEnv = path.join(resolvedRoot, 'session-env', sessionId);
171
+ if (fs.existsSync(sessionEnv)) remove(fs, sessionEnv, removed);
172
+
173
+ const sessionsDir = path.join(resolvedRoot, 'sessions');
174
+ const registrations = readRegistrations(fs, path, sessionsDir);
175
+ const stale = registrations.filter((entry) => entry.sessionId === sessionId && !isPidAlive(entry.pid));
176
+ const others = registrations.filter((entry) => !stale.includes(entry));
177
+ const files = readDir(fs, sessionsDir);
178
+ for (const entry of stale) {
179
+ remove(fs, path.join(sessionsDir, entry.file), removed);
180
+ const keyPrefix = `${entry.pid}.`;
181
+ for (const name of files) {
182
+ if (name.startsWith(keyPrefix) && name.endsWith('.key')) remove(fs, path.join(sessionsDir, name), removed);
183
+ }
184
+ }
185
+
186
+ // The session's CLI starts: its dead registrations and every start the
187
+ // relay recorded. Another session's: its registrations and its recorded
188
+ // starts, whose windows are never touched.
189
+ const startsFile = path.join(resolvedRoot, CLI_STARTS_FILE);
190
+ const recorded = readCliStarts(fs, startsFile);
191
+ const ownStarts = [...stale.map((entry) => entry.startedAt), ...(recorded[sessionId] || [])];
192
+ const otherStarts = [
193
+ ...others.map((entry) => entry.startedAt),
194
+ ...Object.entries(recorded).filter(([id]) => id !== sessionId).flatMap(([, list]) => list),
195
+ ];
196
+ const snapshotsDir = path.join(resolvedRoot, 'shell-snapshots');
197
+ for (const name of readDir(fs, snapshotsDir)) {
198
+ const match = SNAPSHOT_PATTERN.exec(name);
199
+ if (!match) continue;
200
+ const takenAt = Number(match[1]);
201
+ if (!ownStarts.some((startedAt) => inStartWindow(takenAt, startedAt))) continue;
202
+ if (otherStarts.some((startedAt) => inStartWindow(takenAt, startedAt))) continue;
203
+ remove(fs, path.join(snapshotsDir, name), removed);
204
+ }
205
+ if (recorded[sessionId]) {
206
+ delete recorded[sessionId];
207
+ try {
208
+ writeCliStarts(fs, startsFile, recorded);
209
+ } catch {}
210
+ }
211
+
212
+ const projectDir = String(projectDirPath || '').trim() ? path.resolve(projectDirPath) : '';
213
+ if (projectDir && path.dirname(projectDir) === path.join(resolvedRoot, 'projects')) {
214
+ const entries = readDir(fs, projectDir);
215
+ const isEmptyDir = (dir) => {
216
+ try {
217
+ return fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0;
218
+ } catch {
219
+ return false;
220
+ }
221
+ };
222
+ const onlyEmptyMemory = entries.every((name) => name === 'memory' && isEmptyDir(path.join(projectDir, name)));
223
+ if (fs.existsSync(projectDir) && onlyEmptyMemory) remove(fs, projectDir, removed);
224
+ }
225
+ return removed;
226
+ }
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // The DeepSeek account balance for the usage card: `GET /user/balance` with
4
+ // the saved key and base URL (shared/deepseek-api.mjs), only while DeepSeek
5
+ // is on. An answer is kept for a minute, a failure for 15 seconds, and a
6
+ // changed key or endpoint is read afresh. Never throws: a failure is an
7
+ // answer of its own, `{ ok: false, code, error }`, whose text never holds the
8
+ // key, with the last good answer beside it as `previous`.
9
+
10
+ import { createDeepSeekApiClient } from '../../shared/deepseek-api.mjs';
11
+
12
+ const DEFAULT_TTL_MS = 60_000;
13
+ const DEFAULT_ERROR_TTL_MS = 15_000;
14
+ // The usage modal waits for its slowest read, so this one is kept short.
15
+ const DEFAULT_TIMEOUT_MS = 5_000;
16
+
17
+ function redact(text, apiKey) {
18
+ const value = String(text ?? '');
19
+ const key = String(apiKey ?? '').trim();
20
+ return key ? value.split(key).join('[redacted]') : value;
21
+ }
22
+
23
+ export function createDeepSeekBalanceService({
24
+ getSettings = () => null,
25
+ fetchImpl = globalThis.fetch,
26
+ createClient = createDeepSeekApiClient,
27
+ now = () => Date.now(),
28
+ ttlMs = DEFAULT_TTL_MS,
29
+ errorTtlMs = DEFAULT_ERROR_TTL_MS,
30
+ timeoutMs = DEFAULT_TIMEOUT_MS,
31
+ } = {}) {
32
+ let cache = null; // { identity, entry, expiresAt }
33
+ let lastGood = null; // { identity, entry }
34
+ let inflight = null; // { identity, promise }
35
+
36
+ function readSettings() {
37
+ try {
38
+ return getSettings() || null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ async function read(settings) {
45
+ const fetchedAt = new Date(now()).toISOString();
46
+ if (!settings.apiKey) {
47
+ return { ok: false, code: 'not_configured', error: 'DeepSeek API key is not configured', fetchedAt };
48
+ }
49
+ try {
50
+ const client = createClient({ baseUrl: settings.baseUrl, apiKey: settings.apiKey, fetchImpl, timeoutMs });
51
+ const { isAvailable, balances } = await client.getBalance();
52
+ return { ok: true, isAvailable: isAvailable === true, balances: Array.isArray(balances) ? balances : [], fetchedAt };
53
+ } catch (error) {
54
+ return {
55
+ ok: false,
56
+ code: String(error?.code || 'error'),
57
+ error: redact(error?.message || error || 'DeepSeek balance check failed', settings.apiKey).slice(0, 300),
58
+ fetchedAt,
59
+ };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The balance, or null while DeepSeek is off. `{ force: true }` skips the
65
+ * cache (a fresh read is still shared with one already running).
66
+ */
67
+ async function getBalance({ force = false } = {}) {
68
+ const settings = readSettings();
69
+ if (settings?.enabled !== true) return null;
70
+ const identity = `${settings.baseUrl || ''}\n${settings.apiKey || ''}`;
71
+ const answer = (entry, cached) => (entry.ok || lastGood?.identity !== identity
72
+ ? { ...entry, cached }
73
+ : { ...entry, cached, previous: lastGood.entry });
74
+ if (!force && cache && cache.identity === identity && cache.expiresAt > now()) {
75
+ return answer(cache.entry, true);
76
+ }
77
+ if (!inflight || inflight.identity !== identity) {
78
+ const promise = read(settings).then((entry) => {
79
+ cache = { identity, entry, expiresAt: now() + (entry.ok ? ttlMs : errorTtlMs) };
80
+ if (entry.ok) lastGood = { identity, entry };
81
+ return entry;
82
+ }).finally(() => {
83
+ if (inflight?.promise === promise) inflight = null;
84
+ });
85
+ inflight = { identity, promise };
86
+ }
87
+ return answer(await inflight.promise, false);
88
+ }
89
+
90
+ /**
91
+ * The last good answer for the saved key and endpoint, without reading
92
+ * anew; null when there is none (or DeepSeek is off).
93
+ */
94
+ function peekBalance() {
95
+ const settings = readSettings();
96
+ if (settings?.enabled !== true) return null;
97
+ const identity = `${settings.baseUrl || ''}\n${settings.apiKey || ''}`;
98
+ return lastGood?.identity === identity ? lastGood.entry : null;
99
+ }
100
+
101
+ return { getBalance, peekBalance };
102
+ }