@oar-sh/oar 0.9.17 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  2. package/CHANGELOG.md +192 -0
  3. package/README.md +13 -12
  4. package/package.json +1 -1
  5. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  6. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  7. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  9. package/server/claude-worker/claude-sdk-adapter.mjs +35 -9
  10. package/server/claude-worker/claude-session-process.mjs +166 -21
  11. package/server/claude-worker/claude-session-worker-main.mjs +3 -0
  12. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  13. package/server/claude-worker/claude-turn-publisher.mjs +117 -5
  14. package/server/claude-worker/claude-usage-totals.mjs +53 -0
  15. package/server/claude-worker/sdk-message-normalizer.mjs +1 -1
  16. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +9 -9
  19. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  20. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  21. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  22. package/server/db-schema.mjs +33 -1
  23. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -130
  24. package/server/grok-worker/acp-client.mjs +1 -1
  25. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  26. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  27. package/server/mcp/oar-mcp-server.mjs +11 -5
  28. package/server/migrations/0031-queue-provider.mjs +143 -0
  29. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  30. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  31. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  32. package/server/public/app/activity-replay-state.mjs +4 -3
  33. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  34. package/server/public/app/agent-sessions-settings.mjs +1 -1
  35. package/server/public/app/api-client.js +52 -0
  36. package/server/public/app/bootstrap.js +153 -27
  37. package/server/public/app/claude-api-settings-ui.js +362 -0
  38. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  39. package/server/public/app/claude-billing-toggle.mjs +112 -0
  40. package/server/public/app/context-tier-options.mjs +6 -2
  41. package/server/public/app/context-usage-view.mjs +1 -1
  42. package/server/public/app/conversation-provider-indicator.mjs +11 -0
  43. package/server/public/app/conversation-view.js +29 -3
  44. package/server/public/app/inbox-view.mjs +1 -1
  45. package/server/public/app/journal-view.js +127 -10
  46. package/server/public/app/model-selector-options.mjs +17 -1
  47. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  48. package/server/public/app/provider-readiness.mjs +5 -2
  49. package/server/public/app/relay-error-ctas.mjs +50 -0
  50. package/server/public/app/relay-schedules-ui.js +1 -1
  51. package/server/public/app/settings-modal.js +11 -0
  52. package/server/public/app/settings-tabs.js +1 -1
  53. package/server/public/app/socket-handlers.js +18 -1
  54. package/server/public/app/tool-activity-emoji.mjs +4 -3
  55. package/server/public/app/usage-limit-ui.js +136 -5
  56. package/server/public/index.html +189 -3
  57. package/server/relay-tools.md +4 -4
  58. package/server/repositories/conversation-group-repository.mjs +1 -1
  59. package/server/repositories/message-repository.mjs +7 -0
  60. package/server/repositories/session-repository.mjs +8 -1
  61. package/server/routes/ask-user-routes.mjs +1 -1
  62. package/server/routes/conversation-group-routes.mjs +1 -1
  63. package/server/routes/messages-routes.mjs +645 -27
  64. package/server/routes/relay-ask-routes.mjs +3 -3
  65. package/server/routes/relay-event-routes.mjs +2 -2
  66. package/server/routes/relay-schedule-routes.mjs +1 -1
  67. package/server/routes/remote-relay-routes.mjs +3 -3
  68. package/server/routes/sessions-routes.mjs +327 -29
  69. package/server/server-runtime.mjs +363 -26
  70. package/server/services/about-service.mjs +1 -0
  71. package/server/services/background-work-view.mjs +2 -1
  72. package/server/services/claude-api-settings-service.mjs +575 -0
  73. package/server/services/claude-api-usage-service.mjs +361 -0
  74. package/server/services/claude-billing-swap-service.mjs +274 -0
  75. package/server/services/claude-billing-switch-service.mjs +343 -0
  76. package/server/services/plan-usage-claude-api.mjs +445 -0
  77. package/server/services/plan-usage-service.mjs +5 -0
  78. package/server/services/provider-model-selection.mjs +3 -2
  79. package/server/services/provider-reasoning-effort.mjs +9 -0
  80. package/server/services/relay-ask-service.mjs +1 -1
  81. package/server/services/relay-event-service.mjs +3 -2
  82. package/server/services/relay-schedule-service.mjs +1 -1
  83. package/server/services/remote-relay-caller-context.mjs +7 -0
  84. package/server/services/remote-relay-dispatcher.mjs +83 -14
  85. package/server/services/remote-relay-registry-service.mjs +1 -1
  86. package/server/services/session-worker-launch-service.mjs +61 -2
  87. package/server/services/session-worker-registry-service.mjs +28 -1
  88. package/server/services/session-worker-websocket-service.mjs +17 -0
  89. package/server/services/usage-limit-pause-service.mjs +55 -4
  90. package/server/services/worker-secret-redaction.mjs +2 -0
  91. package/shared/anthropic-api.mjs +202 -0
  92. package/shared/provider-routing.mjs +5 -3
  93. package/shared/relay-asks.mjs +11 -5
  94. package/shared/relay-events.mjs +2 -2
  95. package/shared/relay-schedules.mjs +2 -2
  96. package/shared/remote-relay-contract.mjs +29 -9
  97. package/shared/remote-relay-tool-core.mjs +23 -6
  98. package/shared/steer-settle-failure.mjs +1 -0
  99. package/shared/worker-runtime/api-client.mjs +2 -2
  100. package/shared/worker-runtime/heartbeat.mjs +5 -0
  101. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: a queue row names the billing it runs on (the Claude billing
5
+ * switch, services/claude-billing-switch-service.mjs).
6
+ *
7
+ * `queue.provider`: the provider the row was queued under, written at every
8
+ * insert from the chat's `runtime_sessions.provider_type`. A Claude chat can
9
+ * move between `claude` (the subscription) and `claude-api` (the saved API
10
+ * key); the switch re-stamps the chat's rows that have not started, and every
11
+ * requeue re-stamps to the chat's billing of that moment. The relay hands a
12
+ * row only to a worker of that kind, so a turn never runs on a billing nobody
13
+ * asked for. NULL (a row from before this column) means the chat's billing.
14
+ *
15
+ * Two triggers keep the stamp without every writer having to know it:
16
+ * - `queue_provider_after_insert`: a row inserted without a provider (the
17
+ * send route, schedules, events, restart-resume, the board, the usage-limit
18
+ * follow-up) takes its chat's provider when the chat is a Claude or Claude
19
+ * API one. A writer that names one (a continuation: the kind of the worker
20
+ * that runs it) keeps it.
21
+ * - `queue_provider_after_requeue`: a row going back from processing to
22
+ * pending or parked (the stale sweep, dead-worker recovery, a hand-back) is
23
+ * a new turn, and takes the chat's provider of that moment.
24
+ *
25
+ * The back-fill gives the unfinished rows of Claude and Claude API chats
26
+ * (pending, parked, processing) their chat's provider. It touches only rows
27
+ * without one, so it is idempotent; finished rows stay NULL.
28
+ *
29
+ * Additive and idempotent. Safe to run on every boot.
30
+ *
31
+ * Usage: node server/migrations/0031-queue-provider.mjs [path/to/copilot.db]
32
+ */
33
+
34
+ import { fileURLToPath } from 'url';
35
+ import path from 'path';
36
+ import process from 'process';
37
+ import Database from 'better-sqlite3';
38
+
39
+ export const QUEUE_PROVIDER_COLUMN = 'provider';
40
+ export const QUEUE_PROVIDER_TRIGGERS = Object.freeze(['queue_provider_after_insert', 'queue_provider_after_requeue']);
41
+
42
+ // The chat's billing, when it is a Claude one: NULL for every other provider.
43
+ const chatClaudeBillingSql = (conversationIdSql) => `(
44
+ SELECT LOWER(TRIM(rs.provider_type)) FROM runtime_sessions rs
45
+ WHERE rs.conversation_id = ${conversationIdSql}
46
+ AND LOWER(TRIM(rs.provider_type)) IN ('claude', 'claude-api')
47
+ )`;
48
+
49
+ function tableExists(db, name) {
50
+ return !!db
51
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
52
+ .get(name);
53
+ }
54
+
55
+ function columnNames(db, table) {
56
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
57
+ }
58
+
59
+ /** What keeps the relay from stamping rows: '' when nothing does. */
60
+ export function queueProviderProblem(db) {
61
+ if (!tableExists(db, 'queue')) return 'no queue table';
62
+ if (!columnNames(db, 'queue').has(QUEUE_PROVIDER_COLUMN)) return 'queue lacks provider';
63
+ const triggers = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'queue'`).all().map((row) => row.name));
64
+ const missing = QUEUE_PROVIDER_TRIGGERS.filter((name) => !triggers.has(name));
65
+ if (missing.length) return `queue lacks the trigger ${missing.join(', ')}`;
66
+ return '';
67
+ }
68
+
69
+ export function migrateQueueProvider(db) {
70
+ if (!tableExists(db, 'queue')) return { applied: false, columnsAdded: 0, triggersAdded: 0, rowsFilled: 0 };
71
+ // The triggers and the back-fill read provider_type: a table without it
72
+ // waits for the schema boot to add it.
73
+ if (!tableExists(db, 'runtime_sessions') || !columnNames(db, 'runtime_sessions').has('provider_type')) {
74
+ return { applied: false, columnsAdded: 0, triggersAdded: 0, rowsFilled: 0 };
75
+ }
76
+ let columnsAdded = 0;
77
+ if (!columnNames(db, 'queue').has(QUEUE_PROVIDER_COLUMN)) {
78
+ db.exec(`ALTER TABLE queue ADD COLUMN ${QUEUE_PROVIDER_COLUMN} TEXT`);
79
+ columnsAdded += 1;
80
+ }
81
+ const present = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND tbl_name = 'queue'`).all().map((row) => row.name));
82
+ let triggersAdded = 0;
83
+ if (!present.has('queue_provider_after_insert')) {
84
+ db.exec(`
85
+ CREATE TRIGGER queue_provider_after_insert
86
+ AFTER INSERT ON queue
87
+ WHEN NEW.provider IS NULL
88
+ BEGIN
89
+ UPDATE queue SET provider = ${chatClaudeBillingSql('NEW.conversation_id')} WHERE id = NEW.id;
90
+ END
91
+ `);
92
+ triggersAdded += 1;
93
+ }
94
+ if (!present.has('queue_provider_after_requeue')) {
95
+ db.exec(`
96
+ CREATE TRIGGER queue_provider_after_requeue
97
+ AFTER UPDATE OF status ON queue
98
+ WHEN OLD.status = 'processing' AND NEW.status IN ('pending', 'parked')
99
+ BEGIN
100
+ UPDATE queue SET provider = COALESCE(${chatClaudeBillingSql('NEW.conversation_id')}, NEW.provider) WHERE id = NEW.id;
101
+ END
102
+ `);
103
+ triggersAdded += 1;
104
+ }
105
+ const rowsFilled = db.prepare(`
106
+ UPDATE queue
107
+ SET provider = ${chatClaudeBillingSql('queue.conversation_id')}
108
+ WHERE provider IS NULL
109
+ AND status IN ('pending', 'parked', 'processing')
110
+ AND ${chatClaudeBillingSql('queue.conversation_id')} IS NOT NULL
111
+ `).run().changes;
112
+ return { applied: columnsAdded > 0 || triggersAdded > 0 || rowsFilled > 0, columnsAdded, triggersAdded, rowsFilled };
113
+ }
114
+
115
+ function defaultDbPath() {
116
+ const here = path.dirname(fileURLToPath(import.meta.url));
117
+ return path.resolve(here, '..', 'data', 'copilot.db');
118
+ }
119
+
120
+ export function migrate(dbPath, options = {}) {
121
+ const db = new Database(dbPath, options);
122
+ try {
123
+ return migrateQueueProvider(db);
124
+ } finally {
125
+ db.close();
126
+ }
127
+ }
128
+
129
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
130
+ if (isMain) {
131
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
132
+ try {
133
+ const result = migrate(dbPath);
134
+ console.log(`Migration 0031 on ${dbPath}`);
135
+ console.log(result.applied
136
+ ? ` queue provider: columns added ${result.columnsAdded}, triggers added ${result.triggersAdded}, rows filled ${result.rowsFilled}`
137
+ : ' skipped: nothing to change');
138
+ process.exit(0);
139
+ } catch (err) {
140
+ console.error(`Migration 0031 failed: ${err?.message || err}`);
141
+ process.exit(1);
142
+ }
143
+ }
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: a runtime session remembers its last billing switch (the Claude
5
+ * billing switch, services/claude-billing-switch-service.mjs).
6
+ *
7
+ * `runtime_sessions` gains two columns:
8
+ * - `billing_switched_at`: when the chat last moved between `claude` (the
9
+ * subscription) and `claude-api` (the saved API key); NULL for a chat that
10
+ * never did. A worker started before the switch existed reports no kind, and
11
+ * on a chat with this set the relay cannot assume the kind from
12
+ * `provider_type`: it replaces that worker once, when it is idle. A chat
13
+ * never switched has had one billing, which also settles whose turns its
14
+ * older messages are.
15
+ * - `billing_switched_by`: who asked: `user` (the toggle), `agent` (an
16
+ * agent's `send` with a provider) or `usage-limit` (the button on the
17
+ * usage-limit note).
18
+ *
19
+ * Additive and idempotent. Safe to run on every boot.
20
+ *
21
+ * Usage: node server/migrations/0032-runtime-session-billing-switch.mjs [path/to/copilot.db]
22
+ */
23
+
24
+ import { fileURLToPath } from 'url';
25
+ import path from 'path';
26
+ import process from 'process';
27
+ import Database from 'better-sqlite3';
28
+
29
+ // Added to runtime_sessions, with their DDL.
30
+ export const RUNTIME_SESSION_BILLING_SWITCH_COLUMNS = Object.freeze([
31
+ ['billing_switched_at', 'TEXT'],
32
+ ['billing_switched_by', 'TEXT'],
33
+ ]);
34
+
35
+ function tableExists(db, name) {
36
+ return !!db
37
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
38
+ .get(name);
39
+ }
40
+
41
+ function columnNames(db, table) {
42
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
43
+ }
44
+
45
+ /** What keeps the switch from recording itself: '' when nothing does. */
46
+ export function runtimeSessionBillingSwitchProblem(db) {
47
+ if (!tableExists(db, 'runtime_sessions')) return 'no runtime_sessions table';
48
+ const present = columnNames(db, 'runtime_sessions');
49
+ const missing = RUNTIME_SESSION_BILLING_SWITCH_COLUMNS.map(([name]) => name).filter((name) => !present.has(name));
50
+ if (missing.length) return `runtime_sessions lacks ${missing.join(', ')}`;
51
+ return '';
52
+ }
53
+
54
+ export function migrateRuntimeSessionBillingSwitch(db) {
55
+ if (!tableExists(db, 'runtime_sessions')) return { applied: false, columnsAdded: 0 };
56
+ let columnsAdded = 0;
57
+ const present = columnNames(db, 'runtime_sessions');
58
+ for (const [name, ddl] of RUNTIME_SESSION_BILLING_SWITCH_COLUMNS) {
59
+ if (present.has(name)) continue;
60
+ db.exec(`ALTER TABLE runtime_sessions ADD COLUMN ${name} ${ddl}`);
61
+ columnsAdded += 1;
62
+ }
63
+ return { applied: columnsAdded > 0, columnsAdded };
64
+ }
65
+
66
+ function defaultDbPath() {
67
+ const here = path.dirname(fileURLToPath(import.meta.url));
68
+ return path.resolve(here, '..', 'data', 'copilot.db');
69
+ }
70
+
71
+ export function migrate(dbPath, options = {}) {
72
+ const db = new Database(dbPath, options);
73
+ try {
74
+ return migrateRuntimeSessionBillingSwitch(db);
75
+ } finally {
76
+ db.close();
77
+ }
78
+ }
79
+
80
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
81
+ if (isMain) {
82
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
83
+ try {
84
+ const result = migrate(dbPath);
85
+ console.log(`Migration 0032 on ${dbPath}`);
86
+ console.log(result.applied
87
+ ? ` runtime session billing switch: columns added ${result.columnsAdded}`
88
+ : ' skipped: nothing to change');
89
+ process.exit(0);
90
+ } catch (err) {
91
+ console.error(`Migration 0032 failed: ${err?.message || err}`);
92
+ process.exit(1);
93
+ }
94
+ }
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: Claude API usage checkpoints (services/plan-usage-service.mjs,
5
+ * recordClaudeApiUsageReport; the booking behind the Claude API card and its
6
+ * monthly cap).
7
+ *
8
+ * Both Claude workers, the subscription one and the Claude API one, post
9
+ * after every turn (POST /api/claude-api-usage). A chat can move between the
10
+ * two billings and keeps its Claude Code session, so what one billing ran
11
+ * shows up in the totals the other one reports after the switch. One row per
12
+ * (conversation, Claude Code session, CLI process, model):
13
+ *
14
+ * - `conversation_id`, `native_session_id` (the CLI's session id), `run_id`
15
+ * (the id the worker gives each CLI process it spawns), `model`.
16
+ * - `billing`: the kind of the worker that posted: `claude` or `claude-api`.
17
+ * - `run_hit_tokens`, `run_miss_tokens`, `run_output_tokens`, `run_cost_usd`:
18
+ * what this process has run so far, counted from zero, as far as it was
19
+ * booked. A post books its rise over these; one at or below them is a
20
+ * retried or reordered post and books nothing. Only `claude-api` rows book
21
+ * spend.
22
+ * - `session_hit_tokens`, `session_miss_tokens`, `session_output_tokens`,
23
+ * `session_cost_usd`: the CLI's own cumulative totals for the session at
24
+ * that post. The row of a session with the latest `last_turn_at` is the
25
+ * session's mark: a CLI resumed on the session, under either billing,
26
+ * re-reports the earlier turns in its first result, and the mark is what
27
+ * that first result is measured against.
28
+ * - `cost_unknown`: 1 when the CLI had no price for the model.
29
+ * - `last_turn_id` (the queue row of the turn), `last_turn_at` (when its
30
+ * result came): the turn the row covers. A mark is used only when no
31
+ * completed turn of the session is newer than it.
32
+ * - `updated_at`.
33
+ *
34
+ * Additive and idempotent. Safe to run on every boot.
35
+ *
36
+ * Usage: node server/migrations/0033-claude-api-usage-checkpoints.mjs [path/to/copilot.db]
37
+ */
38
+
39
+ import { fileURLToPath } from 'url';
40
+ import path from 'path';
41
+ import process from 'process';
42
+ import Database from 'better-sqlite3';
43
+
44
+ export const CLAUDE_API_USAGE_CHECKPOINTS_TABLE = 'claude_api_usage_checkpoints';
45
+
46
+ export const CLAUDE_API_USAGE_CHECKPOINT_COLUMNS = Object.freeze([
47
+ 'conversation_id',
48
+ 'native_session_id',
49
+ 'run_id',
50
+ 'model',
51
+ 'billing',
52
+ 'run_hit_tokens',
53
+ 'run_miss_tokens',
54
+ 'run_output_tokens',
55
+ 'run_cost_usd',
56
+ 'session_hit_tokens',
57
+ 'session_miss_tokens',
58
+ 'session_output_tokens',
59
+ 'session_cost_usd',
60
+ 'cost_unknown',
61
+ 'last_turn_id',
62
+ 'last_turn_at',
63
+ 'updated_at',
64
+ ]);
65
+
66
+ function tableExists(db, name) {
67
+ return !!db
68
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
69
+ .get(name);
70
+ }
71
+
72
+ /** What keeps the service from using the table: '' when nothing does. */
73
+ export function claudeApiUsageCheckpointsProblem(db) {
74
+ if (!tableExists(db, CLAUDE_API_USAGE_CHECKPOINTS_TABLE)) return `no ${CLAUDE_API_USAGE_CHECKPOINTS_TABLE} table`;
75
+ const present = new Set(db.prepare(`PRAGMA table_info(${CLAUDE_API_USAGE_CHECKPOINTS_TABLE})`).all().map((column) => column.name));
76
+ const missing = CLAUDE_API_USAGE_CHECKPOINT_COLUMNS.filter((name) => !present.has(name));
77
+ if (missing.length) return `${CLAUDE_API_USAGE_CHECKPOINTS_TABLE} lacks ${missing.join(', ')}`;
78
+ return '';
79
+ }
80
+
81
+ export function migrateClaudeApiUsageCheckpoints(db) {
82
+ if (tableExists(db, CLAUDE_API_USAGE_CHECKPOINTS_TABLE)) return { applied: false, tablesAdded: 0 };
83
+ db.exec(`
84
+ CREATE TABLE ${CLAUDE_API_USAGE_CHECKPOINTS_TABLE} (
85
+ conversation_id TEXT NOT NULL,
86
+ native_session_id TEXT NOT NULL,
87
+ run_id TEXT NOT NULL,
88
+ model TEXT NOT NULL,
89
+ billing TEXT NOT NULL,
90
+ run_hit_tokens INTEGER NOT NULL DEFAULT 0,
91
+ run_miss_tokens INTEGER NOT NULL DEFAULT 0,
92
+ run_output_tokens INTEGER NOT NULL DEFAULT 0,
93
+ run_cost_usd REAL NOT NULL DEFAULT 0,
94
+ session_hit_tokens INTEGER NOT NULL DEFAULT 0,
95
+ session_miss_tokens INTEGER NOT NULL DEFAULT 0,
96
+ session_output_tokens INTEGER NOT NULL DEFAULT 0,
97
+ session_cost_usd REAL NOT NULL DEFAULT 0,
98
+ cost_unknown INTEGER NOT NULL DEFAULT 0,
99
+ last_turn_id TEXT,
100
+ last_turn_at TEXT,
101
+ updated_at TEXT NOT NULL,
102
+ PRIMARY KEY (conversation_id, native_session_id, run_id, model)
103
+ )
104
+ `);
105
+ db.exec(`
106
+ CREATE INDEX IF NOT EXISTS idx_claude_api_usage_checkpoints_mark
107
+ ON ${CLAUDE_API_USAGE_CHECKPOINTS_TABLE}(native_session_id, last_turn_at)
108
+ `);
109
+ return { applied: true, tablesAdded: 1 };
110
+ }
111
+
112
+ function defaultDbPath() {
113
+ const here = path.dirname(fileURLToPath(import.meta.url));
114
+ return path.resolve(here, '..', 'data', 'copilot.db');
115
+ }
116
+
117
+ export function migrate(dbPath, options = {}) {
118
+ const db = new Database(dbPath, options);
119
+ try {
120
+ return migrateClaudeApiUsageCheckpoints(db);
121
+ } finally {
122
+ db.close();
123
+ }
124
+ }
125
+
126
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
127
+ if (isMain) {
128
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
129
+ try {
130
+ const result = migrate(dbPath);
131
+ console.log(`Migration 0033 on ${dbPath}`);
132
+ console.log(result.applied ? ` claude api usage checkpoints: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
133
+ process.exit(0);
134
+ } catch (err) {
135
+ console.error(`Migration 0033 failed: ${err?.message || err}`);
136
+ process.exit(1);
137
+ }
138
+ }
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: Claude API usage totals (services/plan-usage-service.mjs, the
5
+ * Claude API card in Check Usage and its monthly cap).
6
+ *
7
+ * `claude_api_usage_totals`: what this relay's turns on the saved Claude API
8
+ * key ran, per day and model; a row only ever grows. Subscription turns are
9
+ * never booked here. The card shows today and this month, and the cap is a
10
+ * calendar month in relay local time, so the day is the relay's local date.
11
+ *
12
+ * - `day`: `YYYY-MM-DD`, the relay's local date of the turn's result.
13
+ * - `model`: the model's id as the CLI reported it, without the `[1m]`
14
+ * suffix, lower case.
15
+ * - `hit_tokens`, `miss_tokens`, `output_tokens`: input read from the
16
+ * prompt cache, other input (cache writes included), and output.
17
+ * - `cost_usd`: Claude Code's own estimate (`modelUsage[*].costUSD`) of what
18
+ * the booked turns cost, for the turns it could price.
19
+ * - `unknown_cost_tokens`: the tokens of turns it could not price
20
+ * (`costBasis: unknown`), which `cost_usd` leaves out.
21
+ * - `turn_count`: the posts that booked something for the model.
22
+ * - `first_seen_at`, `last_seen_at`, `updated_at`.
23
+ *
24
+ * Additive and idempotent. Safe to run on every boot.
25
+ *
26
+ * Usage: node server/migrations/0034-claude-api-usage-totals.mjs [path/to/copilot.db]
27
+ */
28
+
29
+ import { fileURLToPath } from 'url';
30
+ import path from 'path';
31
+ import process from 'process';
32
+ import Database from 'better-sqlite3';
33
+
34
+ export const CLAUDE_API_USAGE_TOTALS_TABLE = 'claude_api_usage_totals';
35
+
36
+ export const CLAUDE_API_USAGE_TOTALS_COLUMNS = Object.freeze([
37
+ 'day',
38
+ 'model',
39
+ 'hit_tokens',
40
+ 'miss_tokens',
41
+ 'output_tokens',
42
+ 'cost_usd',
43
+ 'unknown_cost_tokens',
44
+ 'turn_count',
45
+ 'first_seen_at',
46
+ 'last_seen_at',
47
+ 'updated_at',
48
+ ]);
49
+
50
+ function tableExists(db, name) {
51
+ return !!db
52
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
53
+ .get(name);
54
+ }
55
+
56
+ /** What keeps the service from using the table: '' when nothing does. */
57
+ export function claudeApiUsageTotalsProblem(db) {
58
+ if (!tableExists(db, CLAUDE_API_USAGE_TOTALS_TABLE)) return `no ${CLAUDE_API_USAGE_TOTALS_TABLE} table`;
59
+ const present = new Set(db.prepare(`PRAGMA table_info(${CLAUDE_API_USAGE_TOTALS_TABLE})`).all().map((column) => column.name));
60
+ const missing = CLAUDE_API_USAGE_TOTALS_COLUMNS.filter((name) => !present.has(name));
61
+ if (missing.length) return `${CLAUDE_API_USAGE_TOTALS_TABLE} lacks ${missing.join(', ')}`;
62
+ return '';
63
+ }
64
+
65
+ export function migrateClaudeApiUsageTotals(db) {
66
+ if (tableExists(db, CLAUDE_API_USAGE_TOTALS_TABLE)) return { applied: false, tablesAdded: 0 };
67
+ db.exec(`
68
+ CREATE TABLE ${CLAUDE_API_USAGE_TOTALS_TABLE} (
69
+ day TEXT NOT NULL,
70
+ model TEXT NOT NULL,
71
+ hit_tokens INTEGER NOT NULL DEFAULT 0,
72
+ miss_tokens INTEGER NOT NULL DEFAULT 0,
73
+ output_tokens INTEGER NOT NULL DEFAULT 0,
74
+ cost_usd REAL NOT NULL DEFAULT 0,
75
+ unknown_cost_tokens INTEGER NOT NULL DEFAULT 0,
76
+ turn_count INTEGER NOT NULL DEFAULT 0,
77
+ first_seen_at TEXT NOT NULL,
78
+ last_seen_at TEXT NOT NULL,
79
+ updated_at TEXT NOT NULL,
80
+ PRIMARY KEY (day, model)
81
+ )
82
+ `);
83
+ return { applied: true, tablesAdded: 1 };
84
+ }
85
+
86
+ function defaultDbPath() {
87
+ const here = path.dirname(fileURLToPath(import.meta.url));
88
+ return path.resolve(here, '..', 'data', 'copilot.db');
89
+ }
90
+
91
+ export function migrate(dbPath, options = {}) {
92
+ const db = new Database(dbPath, options);
93
+ try {
94
+ return migrateClaudeApiUsageTotals(db);
95
+ } finally {
96
+ db.close();
97
+ }
98
+ }
99
+
100
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
101
+ if (isMain) {
102
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
103
+ try {
104
+ const result = migrate(dbPath);
105
+ console.log(`Migration 0034 on ${dbPath}`);
106
+ console.log(result.applied ? ` claude api usage totals: tables added ${result.tablesAdded}` : ' skipped: nothing to change');
107
+ process.exit(0);
108
+ } catch (err) {
109
+ console.error(`Migration 0034 failed: ${err?.message || err}`);
110
+ process.exit(1);
111
+ }
112
+ }
@@ -26,12 +26,13 @@ export function isCompactBoundaryActivityEntry(item) {
26
26
 
27
27
  // The live list of one turn keeps its newest ordinary lines only, but every
28
28
  // structured row a line of the transcript is built from: the turn's
29
- // compaction entries and the session-restart note would otherwise scroll out
30
- // of a busy turn and take their line with them until the next reload.
29
+ // compaction entries and the session-restart notes (a compaction window, a
30
+ // billing switch) would otherwise scroll out of a busy turn and take their
31
+ // line with them until the next reload.
31
32
  export const LIVE_ACTIVITY_CAP = 24;
32
33
  function isKeptLiveActivityEntry(item) {
33
34
  const kind = normalizeRelayActivityEntry(item)?.metadata?.kind;
34
- return kind === 'compact_boundary' || kind === 'compact_window_respawn';
35
+ return kind === 'compact_boundary' || kind === 'compact_window_respawn' || kind === 'billing_switch_respawn';
35
36
  }
36
37
  export function capLiveActivityEntries(items, cap = LIVE_ACTIVITY_CAP) {
37
38
  const list = Array.isArray(items) ? items : [];
@@ -1,5 +1,5 @@
1
1
  // Settings → Relays → Agent sessions: the switch that lets agents start and use
2
- // sessions on this relay through the remote_relay tool, and the slider for the
2
+ // sessions on this relay through the relay_tool, and the slider for the
3
3
  // longest wait of one tool call. The decisions live in
4
4
  // agent-sessions-settings.mjs; this file only moves them into the markup in
5
5
  // index.html. The static controls use inline handlers that bootstrap.js
@@ -1,5 +1,5 @@
1
1
  // Pure helpers for Settings → Relays → Agent sessions: whether agents may
2
- // start and use sessions on this relay, and the longest time one remote_relay
2
+ // start and use sessions on this relay, and the longest time one relay_tool
3
3
  // call may wait. DOM-free on purpose, like claude-cloud-ui.mjs: the section
4
4
  // (agent-sessions-settings-ui.js) owns the elements, this module the decisions.
5
5
  //
@@ -575,6 +575,37 @@ export async function updateDeepSeekSettings({
575
575
  return settingsRequest('/api/settings/deepseek', payload, 'Failed to update DeepSeek settings');
576
576
  }
577
577
 
578
+ export async function loadClaudeApiSettings() {
579
+ return apiFetch('/api/settings/claude-api');
580
+ }
581
+
582
+ /**
583
+ * Saves what the caller names on the Claude API tab: a key (written, never
584
+ * read back), the switch, the default model, the enabled models and the
585
+ * monthly cap in US dollars (null = no cap); `remove` takes the key out.
586
+ */
587
+ export async function updateClaudeApiSettings({
588
+ apiKey = '',
589
+ model = undefined,
590
+ enabled = undefined,
591
+ enabledModels = undefined,
592
+ monthlyCapUsd = undefined,
593
+ remove = false,
594
+ } = {}) {
595
+ // Only the fields named here reach the relay: a field the Claude API tab
596
+ // shows needs its line below (api-client.claude-api-settings.test.mjs).
597
+ const payload = remove === true ? { remove: true } : {};
598
+ if (!payload.remove) {
599
+ const key = String(apiKey || '').trim();
600
+ if (key) payload.apiKey = key;
601
+ if (typeof model === 'string' && model.trim()) payload.model = model.trim();
602
+ if (typeof enabled === 'boolean') payload.enabled = enabled;
603
+ if (Array.isArray(enabledModels)) payload.enabledModels = enabledModels;
604
+ if (monthlyCapUsd === null || (typeof monthlyCapUsd === 'number' && Number.isFinite(monthlyCapUsd))) payload.monthlyCapUsd = monthlyCapUsd;
605
+ }
606
+ return settingsRequest('/api/settings/claude-api', payload, 'Failed to update Claude API settings');
607
+ }
608
+
578
609
  /**
579
610
  * Imports DeepSeek's usage export: the texts of its amount and cost files.
580
611
  * Answers `{ ok, summary }`; a refused export throws with the relay's reason.
@@ -1479,6 +1510,27 @@ export async function resumeUsageLimitPause(conversationId) {
1479
1510
  });
1480
1511
  }
1481
1512
 
1513
+ // Move a Claude chat between the subscription ("claude") and the saved API
1514
+ // key ("claude-api"), from its next turn. `by: "usage-limit"` is the
1515
+ // usage-limit note's button. `{ ok: true, ... }`, else `{ ok: false, error,
1516
+ // code }` with the relay's reason (the key, the model, the cap).
1517
+ export async function switchConversationBilling(conversationId, { to, by = 'user' } = {}) {
1518
+ let refusal = null;
1519
+ const result = await apiFetch(`/api/conversation/${encodeURIComponent(String(conversationId || ''))}/billing`, {
1520
+ method: 'POST',
1521
+ body: JSON.stringify({ to, by }),
1522
+ onErrorResponse: async (response) => {
1523
+ const payload = await response.json().catch(() => null);
1524
+ refusal = {
1525
+ ok: false,
1526
+ error: readableServerError(payload?.error) || `The relay answered ${response.status}.`,
1527
+ code: payload?.code || null,
1528
+ };
1529
+ },
1530
+ });
1531
+ return result || refusal || { ok: false, error: 'The relay could not be reached.', code: null };
1532
+ }
1533
+
1482
1534
  export async function cancelUsageLimitPause(conversationId) {
1483
1535
  return apiFetch('/api/usage-limit/cancel', {
1484
1536
  method: 'POST',