@oar-sh/oar 0.9.19 → 0.9.20

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. package/shared/turn-token-usage.mjs +144 -0
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: tokens per turn for every provider, and their daily sums
5
+ * (services/token-usage-service.mjs, the Tokens sections in Check Usage and
6
+ * the About panel).
7
+ *
8
+ * `turn_token_usage`: one row per turn, run and model, kept with the chat
9
+ * and deleted with it.
10
+ * - `conversation_id`; `turn_id`: the queue row id (the user message's id);
11
+ * no foreign key, as queue rows are pruned.
12
+ * - `run_id`: the worker process or attempt ('' for none), so a re-run after
13
+ * a crash keeps the first attempt's row.
14
+ * - `model`: lower case, without `[1m]`. `provider`: the relay's provider
15
+ * kind. `billing`: '' or `byok-<provider type>` (Copilot's own key).
16
+ * - `native_session_id`, `response_message_id`: NULL when not known.
17
+ * - `at`: the turn's first report (ISO); `day`: its relay local date
18
+ * (`YYYY-MM-DD`), fixed at the first report.
19
+ * - `input_tokens` (fresh input, no cache), `output_tokens`,
20
+ * `cache_write_tokens`, `cache_read_tokens`, `reasoning_tokens` (part of
21
+ * output): NULL when the provider did not report the field; 0 when it
22
+ * reported zero.
23
+ * - `cost_usd` (NULL when not known), `cost_source` (`claude-code`,
24
+ * `provider`, `oar-deepseek-table`), `cost_unknown`.
25
+ *
26
+ * `token_usage_days`: the sums per day, provider, billing, model and source
27
+ * (`turns`, or `backfill` for the copy below). Its rows survive the chats.
28
+ * A token field stays NULL until some turn reports it, then it is a sum.
29
+ *
30
+ * Backfill, in the transaction that creates the tables (a `token_usage_days`
31
+ * table that exists means it ran): the Claude API totals (0034, local days)
32
+ * and DeepSeek's days (0025, UTC days, kept as they are) are copied with
33
+ * `source='backfill'`. Their `miss_tokens` become `input_tokens` (cache
34
+ * writes included: the views say so), `hit_tokens` `cache_read_tokens`, and
35
+ * `cache_write_tokens` stays NULL. Model ids are normalised as above, and
36
+ * rows that meet on one key are summed. DeepSeek's `cost_usd` is the token
37
+ * estimate alone (its tools cost stays on the DeepSeek card; a NULL stays
38
+ * NULL), and its days keep no seen times, so the day's bounds stand in. A
39
+ * source that 0034's or 0025's own check finds missing or partial (its
40
+ * migration failed this boot) makes this one throw, so the schema boot logs
41
+ * it and retries on the next boot once the source is there.
42
+ *
43
+ * Additive and idempotent. Safe to run on every boot.
44
+ *
45
+ * Usage: node server/migrations/0036-token-usage.mjs [path/to/oar.db]
46
+ */
47
+
48
+ import { fileURLToPath } from 'url';
49
+ import path from 'path';
50
+ import process from 'process';
51
+ import Database from 'better-sqlite3';
52
+ import { cliDbPath } from '../services/database-file.mjs';
53
+
54
+ import { CLAUDE_API_USAGE_TOTALS_TABLE, claudeApiUsageTotalsProblem } from './0034-claude-api-usage-totals.mjs';
55
+ import { DEEPSEEK_USAGE_DAYS_TABLE, deepseekUsageRequestsDailyProblem } from './0025-deepseek-usage-requests-daily.mjs';
56
+
57
+ export const TURN_TOKEN_USAGE_TABLE = 'turn_token_usage';
58
+ export const TOKEN_USAGE_DAYS_TABLE = 'token_usage_days';
59
+ export const TURN_TOKEN_USAGE_INDEXES = Object.freeze([
60
+ 'idx_turn_token_usage_conversation',
61
+ 'idx_turn_token_usage_native_session',
62
+ ]);
63
+
64
+ export const TURN_TOKEN_USAGE_COLUMNS = Object.freeze([
65
+ 'conversation_id',
66
+ 'turn_id',
67
+ 'run_id',
68
+ 'model',
69
+ 'provider',
70
+ 'billing',
71
+ 'native_session_id',
72
+ 'response_message_id',
73
+ 'at',
74
+ 'day',
75
+ 'input_tokens',
76
+ 'output_tokens',
77
+ 'cache_write_tokens',
78
+ 'cache_read_tokens',
79
+ 'reasoning_tokens',
80
+ 'cost_usd',
81
+ 'cost_source',
82
+ 'cost_unknown',
83
+ 'updated_at',
84
+ ]);
85
+
86
+ export const TOKEN_USAGE_DAYS_COLUMNS = Object.freeze([
87
+ 'day',
88
+ 'provider',
89
+ 'billing',
90
+ 'model',
91
+ 'source',
92
+ 'input_tokens',
93
+ 'output_tokens',
94
+ 'cache_write_tokens',
95
+ 'cache_read_tokens',
96
+ 'reasoning_tokens',
97
+ 'cost_usd',
98
+ 'unknown_cost_tokens',
99
+ 'turn_count',
100
+ 'first_seen_at',
101
+ 'last_seen_at',
102
+ 'updated_at',
103
+ ]);
104
+
105
+ function tableExists(db, name) {
106
+ return !!db
107
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
108
+ .get(name);
109
+ }
110
+
111
+ function missingColumns(db, table, expected) {
112
+ const present = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
113
+ return expected.filter((name) => !present.has(name));
114
+ }
115
+
116
+ /** What keeps the service from using the tables: '' when nothing does. */
117
+ export function tokenUsageProblem(db) {
118
+ for (const [table, expected] of [
119
+ [TURN_TOKEN_USAGE_TABLE, TURN_TOKEN_USAGE_COLUMNS],
120
+ [TOKEN_USAGE_DAYS_TABLE, TOKEN_USAGE_DAYS_COLUMNS],
121
+ ]) {
122
+ if (!tableExists(db, table)) return `no ${table} table`;
123
+ const missing = missingColumns(db, table, expected);
124
+ if (missing.length) return `${table} lacks ${missing.join(', ')}`;
125
+ }
126
+ return '';
127
+ }
128
+
129
+ // The tables' model id: lower case, without the `[1m]` suffix
130
+ // (shared/turn-token-usage.mjs), in SQL.
131
+ const BOOKED_MODEL_SQL = `CASE WHEN LOWER(TRIM(model)) LIKE '%[1m]' THEN SUBSTR(LOWER(TRIM(model)), 1, LENGTH(TRIM(model)) - 4) ELSE LOWER(TRIM(model)) END`;
132
+
133
+ // Two source rows can land on one key once their model ids are normalised:
134
+ // they are summed, null-aware.
135
+ const BACKFILL_UPSERT = `
136
+ ON CONFLICT (day, provider, billing, model, source) DO UPDATE SET
137
+ input_tokens = CASE WHEN excluded.input_tokens IS NULL THEN input_tokens ELSE COALESCE(input_tokens, 0) + excluded.input_tokens END,
138
+ output_tokens = CASE WHEN excluded.output_tokens IS NULL THEN output_tokens ELSE COALESCE(output_tokens, 0) + excluded.output_tokens END,
139
+ cache_read_tokens = CASE WHEN excluded.cache_read_tokens IS NULL THEN cache_read_tokens ELSE COALESCE(cache_read_tokens, 0) + excluded.cache_read_tokens END,
140
+ cost_usd = CASE WHEN excluded.cost_usd IS NULL THEN cost_usd ELSE COALESCE(cost_usd, 0) + excluded.cost_usd END,
141
+ unknown_cost_tokens = unknown_cost_tokens + excluded.unknown_cost_tokens,
142
+ turn_count = turn_count + excluded.turn_count,
143
+ first_seen_at = MIN(first_seen_at, excluded.first_seen_at),
144
+ last_seen_at = MAX(last_seen_at, excluded.last_seen_at)
145
+ `;
146
+
147
+ export function migrateTokenUsage(db, { now = () => new Date().toISOString() } = {}) {
148
+ return db.transaction(() => {
149
+ let tablesAdded = 0;
150
+ const backfilled = { claudeApi: 0, deepseek: 0 };
151
+ // Done means both tables exist, checked inside the transaction.
152
+ if (tableExists(db, TOKEN_USAGE_DAYS_TABLE) && tableExists(db, TURN_TOKEN_USAGE_TABLE)) {
153
+ return { applied: false, tablesAdded, backfilled };
154
+ }
155
+ if (!tableExists(db, TURN_TOKEN_USAGE_TABLE)) {
156
+ db.exec(`
157
+ CREATE TABLE ${TURN_TOKEN_USAGE_TABLE} (
158
+ conversation_id TEXT NOT NULL,
159
+ turn_id TEXT NOT NULL,
160
+ run_id TEXT NOT NULL DEFAULT '',
161
+ model TEXT NOT NULL,
162
+ provider TEXT NOT NULL,
163
+ billing TEXT NOT NULL DEFAULT '',
164
+ native_session_id TEXT,
165
+ response_message_id TEXT,
166
+ at TEXT NOT NULL,
167
+ day TEXT NOT NULL,
168
+ input_tokens INTEGER,
169
+ output_tokens INTEGER,
170
+ cache_write_tokens INTEGER,
171
+ cache_read_tokens INTEGER,
172
+ reasoning_tokens INTEGER,
173
+ cost_usd REAL,
174
+ cost_source TEXT,
175
+ cost_unknown INTEGER NOT NULL DEFAULT 0,
176
+ updated_at TEXT NOT NULL,
177
+ PRIMARY KEY (turn_id, run_id, model)
178
+ );
179
+ CREATE INDEX IF NOT EXISTS ${TURN_TOKEN_USAGE_INDEXES[0]} ON ${TURN_TOKEN_USAGE_TABLE} (conversation_id, at);
180
+ CREATE INDEX IF NOT EXISTS ${TURN_TOKEN_USAGE_INDEXES[1]} ON ${TURN_TOKEN_USAGE_TABLE} (native_session_id);
181
+ `);
182
+ tablesAdded += 1;
183
+ }
184
+ if (!tableExists(db, TOKEN_USAGE_DAYS_TABLE)) {
185
+ // The copy needs both sources as 0034 and 0025 leave them; a missing or
186
+ // partial one (its migration failed this boot) waits for the next boot.
187
+ for (const problem of [claudeApiUsageTotalsProblem(db), deepseekUsageRequestsDailyProblem(db)]) {
188
+ if (problem) throw new Error(`cannot backfill yet: ${problem}`);
189
+ }
190
+ db.exec(`
191
+ CREATE TABLE ${TOKEN_USAGE_DAYS_TABLE} (
192
+ day TEXT NOT NULL,
193
+ provider TEXT NOT NULL,
194
+ billing TEXT NOT NULL DEFAULT '',
195
+ model TEXT NOT NULL,
196
+ source TEXT NOT NULL,
197
+ input_tokens INTEGER,
198
+ output_tokens INTEGER,
199
+ cache_write_tokens INTEGER,
200
+ cache_read_tokens INTEGER,
201
+ reasoning_tokens INTEGER,
202
+ cost_usd REAL,
203
+ unknown_cost_tokens INTEGER NOT NULL DEFAULT 0,
204
+ turn_count INTEGER NOT NULL DEFAULT 0,
205
+ first_seen_at TEXT NOT NULL,
206
+ last_seen_at TEXT NOT NULL,
207
+ updated_at TEXT NOT NULL,
208
+ PRIMARY KEY (day, provider, billing, model, source)
209
+ )
210
+ `);
211
+ tablesAdded += 1;
212
+ const at = now();
213
+ const columns = `
214
+ day, provider, billing, model, source,
215
+ input_tokens, output_tokens, cache_write_tokens, cache_read_tokens, reasoning_tokens,
216
+ cost_usd, unknown_cost_tokens, turn_count, first_seen_at, last_seen_at, updated_at
217
+ `;
218
+ // `WHERE true`: SQLite's parser needs it before an upsert on a SELECT.
219
+ backfilled.claudeApi = db.prepare(`
220
+ INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (${columns})
221
+ SELECT day, 'claude-api', '', ${BOOKED_MODEL_SQL}, 'backfill',
222
+ miss_tokens, output_tokens, NULL, hit_tokens, NULL,
223
+ cost_usd, COALESCE(unknown_cost_tokens, 0), COALESCE(turn_count, 0), first_seen_at, last_seen_at, ?
224
+ FROM ${CLAUDE_API_USAGE_TOTALS_TABLE} WHERE true
225
+ ${BACKFILL_UPSERT}
226
+ `).run(at).changes;
227
+ // DeepSeek's days keep no seen times: the day's start and end stand in
228
+ // (UTC days). Its token estimate only, in dollars; no unknown tokens.
229
+ backfilled.deepseek = db.prepare(`
230
+ INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (${columns})
231
+ SELECT day, 'deepseek', '', ${BOOKED_MODEL_SQL}, 'backfill',
232
+ miss_tokens, output_tokens, NULL, hit_tokens, NULL,
233
+ cost_usd, 0, COALESCE(turn_count, 0), day || 'T00:00:00.000Z', day || 'T23:59:59.999Z', ?
234
+ FROM ${DEEPSEEK_USAGE_DAYS_TABLE} WHERE true
235
+ ${BACKFILL_UPSERT}
236
+ `).run(at).changes;
237
+ }
238
+ return { applied: true, tablesAdded, backfilled };
239
+ })();
240
+ }
241
+
242
+ export function migrate(dbPath, options = {}) {
243
+ const db = new Database(dbPath, options);
244
+ try {
245
+ return migrateTokenUsage(db);
246
+ } finally {
247
+ db.close();
248
+ }
249
+ }
250
+
251
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
252
+ if (isMain) {
253
+ try {
254
+ // The given path, else the database in server/data (oar.db, else the
255
+ // name before 0.9.20); never a new one.
256
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
257
+ const result = migrate(dbPath);
258
+ console.log(`Migration 0036 on ${dbPath}`);
259
+ console.log(result.applied
260
+ ? ` token usage: tables added ${result.tablesAdded}, backfilled days: claude api ${result.backfilled.claudeApi}, deepseek ${result.backfilled.deepseek}`
261
+ : ' skipped: nothing to change');
262
+ process.exit(0);
263
+ } catch (err) {
264
+ console.error(`Migration 0036 failed: ${err?.message || err}`);
265
+ process.exit(1);
266
+ }
267
+ }
@@ -13,9 +13,18 @@ const STATE_LABELS = Object.freeze({
13
13
 
14
14
  const USAGE_LABELS = Object.freeze({
15
15
  github: 'GitHub Copilot',
16
+ claude: 'Claude',
17
+ 'claude-api': 'Claude API',
18
+ 'claude-cloud': 'Claude Cloud',
16
19
  cursor: 'Cursor',
17
20
  grok: 'Grok',
18
- 'claude-cloud': 'Claude Cloud',
21
+ deepseek: 'DeepSeek',
22
+ });
23
+
24
+ const OWN_KEY_LABELS = Object.freeze({
25
+ 'byok-openai': 'OpenAI',
26
+ 'byok-anthropic': 'Anthropic',
27
+ 'byok-azure': 'Azure OpenAI',
19
28
  });
20
29
 
21
30
  function esc(value) {
@@ -133,11 +142,67 @@ function providerState(provider) {
133
142
  return `<span class="about-state" data-state="${esc(state)}">${esc(STATE_LABELS[state])}</span>${esc(count)}`;
134
143
  }
135
144
 
145
+ // A token count of the token booking: "—" where no turn reported it.
146
+ function knownCount(value) {
147
+ return value === null || value === undefined || value === '' ? null : finite(value);
148
+ }
149
+
150
+ function tokenAmount(value) {
151
+ return knownCount(value) === null ? '—' : formatAmount(value);
152
+ }
153
+
154
+ /**
155
+ * "Oct 29, 2026" for a `YYYY-MM-DD` day, as Check Usage writes it, joined
156
+ * by no-break spaces so a phone never wraps inside it.
157
+ */
158
+ export function aboutDayLabel(day) {
159
+ const date = new Date(`${day}T00:00:00.000Z`);
160
+ if (!day || Number.isNaN(date.getTime())) return String(day || '');
161
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).replace(/ /g, '\u00a0');
162
+ }
163
+
164
+ /**
165
+ * The all-time tokens: their sum, and the four kinds with the first day.
166
+ * Days copied at the upgrade (Claude API, DeepSeek) hold cache writes inside
167
+ * input, and the hint says until when.
168
+ */
169
+ function tokenRow(label, tokens) {
170
+ const fields = [tokens.inputTokens, tokens.outputTokens, tokens.cacheWriteTokens, tokens.cacheReadTokens];
171
+ const known = fields.map(knownCount).filter((value) => value !== null);
172
+ const total = known.length ? known.reduce((sum, value) => sum + value, 0) : null;
173
+ const parts = [`in ${tokenAmount(tokens.inputTokens)} · out ${tokenAmount(tokens.outputTokens)} · cache w ${tokenAmount(tokens.cacheWriteTokens)} · cache r ${tokenAmount(tokens.cacheReadTokens)}`];
174
+ if (tokens.since) parts.push(`since ${aboutDayLabel(tokens.since)}`);
175
+ if (tokens.backfilled) {
176
+ parts.push(tokens.firstTurnsDay
177
+ ? `input includes cache writes before ${aboutDayLabel(tokens.firstTurnsDay)}`
178
+ : 'input includes cache writes (days from before the upgrade)');
179
+ }
180
+ // One unbroken part: on a phone the unit stays with its number (the
181
+ // section's values otherwise wrap anywhere, for paths).
182
+ return row(label, `<span class="plan-usage-detail-part">${esc(tokenAmount(total))}</span>`, { hint: parts.join(' · ') });
183
+ }
184
+
185
+ function tokenRows(usage) {
186
+ const rows = [];
187
+ if (usage.tokens) rows.push(tokenRow('Tokens (all time)', usage.tokens));
188
+ for (const entry of Array.isArray(usage.ownKeyTokens) ? usage.ownKeyTokens : []) {
189
+ const key = OWN_KEY_LABELS[entry.billing] || String(entry.billing || '').replace(/^byok-/, '') || 'own';
190
+ rows.push(tokenRow(`Tokens (all time), own ${key} key`, entry));
191
+ }
192
+ return rows;
193
+ }
194
+
136
195
  function usageRows(id, usage) {
196
+ return [...providerUsageRows(id, usage), ...tokenRows(usage)];
197
+ }
198
+
199
+ function providerUsageRows(id, usage) {
137
200
  if (id === 'github') {
201
+ if (usage.premiumRequests === undefined) return [];
138
202
  return [row('Premium requests', esc(formatAmount(usage.premiumRequests)), { hint: `over ${formatCount(usage.turns)} replies` })];
139
203
  }
140
204
  if (id === 'cursor') {
205
+ if (usage.totalTokens === undefined) return [];
141
206
  return [
142
207
  row('Tokens', esc(formatAmount(usage.totalTokens)), {
143
208
  hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out · ${formatAmount((finite(usage.cacheReadTokens) || 0) + (finite(usage.cacheWriteTokens) || 0))} cache`,
@@ -146,12 +211,14 @@ function usageRows(id, usage) {
146
211
  ];
147
212
  }
148
213
  if (id === 'grok') {
214
+ if (usage.totalTokens === undefined) return [];
149
215
  return [
150
216
  row('Tokens', esc(formatAmount(usage.totalTokens)), { hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out` }),
151
217
  row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.turns)} turns` }),
152
218
  ];
153
219
  }
154
220
  if (id === 'claude-cloud') {
221
+ if (usage.costUsd === undefined) return [];
155
222
  return [row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.sessions)} cloud sessions` })];
156
223
  }
157
224
  return [];
@@ -173,6 +240,22 @@ function buildUpdateSection(update, formatDateTime) {
173
240
  ));
174
241
  }
175
242
 
243
+ // The database backups: the last result (time, size or why not), how many of
244
+ // how many are kept, and the folder.
245
+ function backupsRow(backups, formatDateTime) {
246
+ const last = backups.lastResult || null;
247
+ let value = 'none yet';
248
+ if (last?.status === 'written') value = `${formatDateTime(last.at)} · ${formatBytes(last.bytes)}`;
249
+ else if (last?.status === 'skipped') value = `skipped ${formatDateTime(last.at)}: ${last.reason || ''}`;
250
+ else if (last?.status === 'failed') value = `failed ${formatDateTime(last.at)}: ${last.reason || ''}`;
251
+ else if (last?.status === 'busy') value = `busy ${formatDateTime(last.at)}: ${last.reason || ''}`;
252
+ const kept = `${formatCount(backups.kept)} kept of ${formatCount(backups.keep)}`;
253
+ const hint = backups.enabled === false
254
+ ? `at start: off (dbBackups.enabled); ${kept}`
255
+ : [kept, backups.problem || backups.dir].filter(Boolean).join(' · ');
256
+ return row('Backups', esc(value), { hint });
257
+ }
258
+
176
259
  /**
177
260
  * @param {object} about the GET /api/about payload
178
261
  * @param {object} [options]
@@ -217,13 +300,14 @@ export function buildAboutModalHtml(about = {}, {
217
300
  sections.push(section('usage', 'Usage', (usageIds.length
218
301
  ? usageIds.map((id) => `<div class="about-usage-provider">${esc(USAGE_LABELS[id])}</div>${table(usageRows(id, usage[id]))}`).join('')
219
302
  : note('No provider has reported usage yet.'))
220
- + note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session. Claude Code and OpenAI turns keep no usage here.')));
303
+ + note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session, and every provider\'s tokens of all time: fresh input, output, cache writes and cache reads, "—" where no turn reported one. On Claude API and DeepSeek the days from before the upgrade have cache writes inside input. The tokens stay when a chat is deleted; Check Usage shows them by day and model.')));
221
304
 
222
305
  const relay = about.relay || {};
223
306
  sections.push(section('relay', 'Relay', table([
224
307
  row('Uptime', esc(formatUptime(relay.uptimeSeconds))),
225
- row('Database', esc(formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
308
+ row('Database', esc(relay.dbPath ? `${relay.dbPath} · ${formatBytes(relay.dbBytes)}` : formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
226
309
  row('Running workers', esc(formatCount(relay.runningWorkers))),
310
+ ...(about.backups ? [backupsRow(about.backups, formatDateTime)] : []),
227
311
  ])));
228
312
 
229
313
  sections.push(buildUpdateSection(about.update || {}, formatDateTime));
@@ -1709,13 +1709,14 @@ export async function loadRelayBoard(boardId) {
1709
1709
  return apiFetch(`/api/relay-board/${encodeURIComponent(id)}`);
1710
1710
  }
1711
1711
 
1712
- export async function submitRelayBoardAction(boardId, actionId) {
1712
+ export async function submitRelayBoardAction(boardId, actionId, { onErrorResponse } = {}) {
1713
1713
  const id = String(boardId || '').trim();
1714
1714
  const action = String(actionId || '').trim();
1715
1715
  if (!id || !action) return null;
1716
1716
  return apiFetch(`/api/relay-board/${encodeURIComponent(id)}/action`, {
1717
1717
  method: 'POST',
1718
1718
  body: JSON.stringify({ actionId: action }),
1719
+ onErrorResponse,
1719
1720
  });
1720
1721
  }
1721
1722
 
@@ -242,6 +242,7 @@ import {
242
242
  autoCompactWindowNotes,
243
243
  renderAutoCompactControlHtml,
244
244
  renderAgentSessionsControlHtml,
245
+ renderPlanOnlyControlHtml,
245
246
  renderAutoCompactNotesHtml,
246
247
  renderContextUsageHtml,
247
248
  renderThinkingControlHtml,
@@ -441,6 +442,7 @@ import {
441
442
  dismissUpdateOutcome,
442
443
  } from './update-ui.js';
443
444
  import { shouldAutofocusComposerOnLoad } from './composer-keyboard.mjs';
445
+ import { planOnlyModeHint } from './plan-board-shared.mjs';
444
446
 
445
447
  const MODEL_STORAGE_KEY = 'copilot_selected_model';
446
448
  // The New Chat modal used to keep its own model key, so a selection made there
@@ -2068,6 +2070,21 @@ function updateModeSelectorForProvider() {
2068
2070
  select.value = modes.includes(selectedBefore)
2069
2071
  ? selectedBefore
2070
2072
  : (modes.includes(FALLBACK_MODE) ? FALLBACK_MODE : modes[0]);
2073
+ syncModeSelectPlanOnlyHint();
2074
+ }
2075
+
2076
+ // A plan-only session's mode select says so; the owner may still
2077
+ // pick any mode.
2078
+ function syncModeSelectPlanOnlyHint() {
2079
+ const select = document.getElementById('mode-select');
2080
+ if (!select) return;
2081
+ const planOnly = conversations[String(currentConvId || '').trim()]?.planOnly === true;
2082
+ const hint = planOnlyModeHint(planOnly);
2083
+ select.title = hint.title;
2084
+ if (planOnly) select.dataset.planOnly = 'true';
2085
+ else delete select.dataset.planOnly;
2086
+ const planOption = Array.from(select.options).find((option) => option.value === 'plan');
2087
+ if (planOption) planOption.textContent = planOnly ? hint.planLabel : (RELAY_MODE_LABELS.plan || hint.planLabel);
2071
2088
  }
2072
2089
 
2073
2090
  function modeOptions() {
@@ -2086,6 +2103,8 @@ async function persistCurrentConversationPreferences() {
2086
2103
  if (!modeSelect || !modelSelect) return;
2087
2104
  const mode = String(modeSelect.value || '').trim() || FALLBACK_MODE;
2088
2105
  const model = String(modelSelect.value || '').trim();
2106
+ // Steer or Queue: a mode across plan from the live turn's queues.
2107
+ syncComposerButtonState();
2089
2108
  // The raw value, not selectedReasoningEffortValue(): a model with no reasoning
2090
2109
  // options leaves the selector empty, and that helper's 'none' fallback would
2091
2110
  // record an effort the user never picked when they only changed the mode.
@@ -2203,6 +2222,7 @@ function applyConversationPreferences({
2203
2222
  } finally {
2204
2223
  suppressConversationPreferenceSync = false;
2205
2224
  }
2225
+ syncModeSelectPlanOnlyHint();
2206
2226
  // Nothing is written to the shared "last used" storage here. Applying a
2207
2227
  // conversation's stored preferences is not a choice: it runs on every open,
2208
2228
  // on the ~1s live poll, on a catalog refresh and on another client's edit,
@@ -3536,6 +3556,12 @@ function initAgentSessionsControl() {
3536
3556
  if (!body || body.dataset.agentSessionsControlBound === 'true') return;
3537
3557
  body.dataset.agentSessionsControlBound = 'true';
3538
3558
  body.addEventListener('change', async (event) => {
3559
+ // Plan-only, for every session.
3560
+ const planOnlyBox = event.target?.closest?.('#ctx-plan-only');
3561
+ if (planOnlyBox) {
3562
+ await changePlanOnly(planOnlyBox);
3563
+ return;
3564
+ }
3539
3565
  const box = event.target?.closest?.('#ctx-may-create-sessions');
3540
3566
  if (!box) return;
3541
3567
  const convId = String(box.dataset.conversationId || '').trim();
@@ -3562,6 +3588,31 @@ function initAgentSessionsControl() {
3562
3588
  });
3563
3589
  }
3564
3590
 
3591
+ async function changePlanOnly(box) {
3592
+ const convId = String(box.dataset.conversationId || '').trim();
3593
+ if (!convId || agentSessionsControlUpdateInFlight) return;
3594
+ const wanted = box.checked === true;
3595
+ agentSessionsControlUpdateInFlight = true;
3596
+ box.disabled = true;
3597
+ try {
3598
+ const response = await updateConversationPreferences(convId, { clientId: CLIENT_ID, planOnly: wanted });
3599
+ if (!response) throw new Error('The relay refused the change');
3600
+ const stored = response.planOnly === true;
3601
+ if (conversations[convId]) conversations[convId] = { ...conversations[convId], planOnly: stored };
3602
+ box.checked = stored;
3603
+ if (convId === String(currentConvId || '').trim()) syncModeSelectPlanOnlyHint();
3604
+ showTransientRelayNotice(stored
3605
+ ? 'Plan only: agents can no longer take this session out of plan mode.'
3606
+ : 'Plan only is off: agents may switch this session to another mode.');
3607
+ } catch (error) {
3608
+ box.checked = !wanted;
3609
+ showTransientRelayNotice(`Could not change the setting: ${error?.message || error}`);
3610
+ } finally {
3611
+ box.disabled = false;
3612
+ agentSessionsControlUpdateInFlight = false;
3613
+ }
3614
+ }
3615
+
3565
3616
  function initThinkingControl() {
3566
3617
  const body = document.getElementById('summary-modal-body');
3567
3618
  if (!body || body.dataset.thinkingBound === '1') return;
@@ -3741,9 +3792,11 @@ async function loadContextSummaryAndRender(convId) {
3741
3792
  // sessions? Only meaningful there, so only shown there.
3742
3793
  const settingsConversationId = String(payload.resolvedConversationId || trimmedConvId || '').trim();
3743
3794
  const settingsConversation = settingsConversationId ? conversations[settingsConversationId] : null;
3744
- const agentSessionsHtml = settingsConversation?.origin
3795
+ const agentSessionsHtml = (settingsConversation?.origin
3745
3796
  ? renderAgentSessionsControlHtml({ conversationId: settingsConversationId, mayCreateSessions: settingsConversation.mayCreateSessions === true })
3746
- : '';
3797
+ : '')
3798
+ // Plan-only, for every session.
3799
+ + (settingsConversationId ? renderPlanOnlyControlHtml({ conversationId: settingsConversationId, planOnly: settingsConversation?.planOnly === true }) : '');
3747
3800
  const detailText = String(payload.text || '').trim();
3748
3801
  // The structured breakdown is the answer; the runtime's own text dump stays
3749
3802
  // available underneath for the details it carries that categories don't.
@@ -19,6 +19,9 @@ export function hasUploadingAttachments(attachments = []) {
19
19
  return countUploadingAttachments(attachments) > 0;
20
20
  }
21
21
 
22
+ // A message whose mode is on the other side of plan mode from the live turn's.
23
+ export const PLAN_BOUNDARY_QUEUE_TITLE = 'Queued behind the running turn: plan mode and the writing modes do not steer into each other';
24
+
22
25
  // Why a message sent right now queues instead of steering — worker-reported
23
26
  // hold reasons mapped to a human title. Stopping a turn lives on the message
24
27
  // bubbles, never here, so every state below is a Send/Steer/Queue shape.
@@ -49,6 +52,10 @@ export function deriveComposerControlState({
49
52
  // the relay holds the message and it steers in once the hold clears.
50
53
  steeringHeld = false,
51
54
  steeringHoldReason = null,
55
+ // The composer's mode is on the other side of plan mode from the live turn's:
56
+ // the relay queues such a message behind the turn instead of steering it in
57
+ // (a steer would switch what the running turn may do).
58
+ crossesPlanMode = false,
52
59
  // An archived conversation: its worker is stopped and it is out of the
53
60
  // live list; sending would start it again behind the user's back.
54
61
  archived = false,
@@ -83,10 +90,13 @@ export function deriveComposerControlState({
83
90
  // The label/title/action for a draft typed during a live turn: steer into it
84
91
  // where the provider supports that (queued while steering is held), queue
85
92
  // behind it otherwise.
86
- const steerNow = !!steeringSupported && !held;
93
+ const crossing = active && !!crossesPlanMode;
94
+ const steerNow = !!steeringSupported && !held && !crossing;
87
95
  const midTurnAction = steerNow ? 'steer' : 'queue';
88
96
  const midTurnLabel = steerNow ? 'Steer' : 'Queue';
89
- const midTurnTitle = held
97
+ const midTurnTitle = crossing
98
+ ? PLAN_BOUNDARY_QUEUE_TITLE
99
+ : held
90
100
  ? (STEERING_HOLD_TITLES[String(steeringHoldReason || '').trim()]
91
101
  || 'Will steer in once steering resumes')
92
102
  : (steeringSupported
@@ -367,6 +367,22 @@ export function renderAgentSessionsControlHtml({ conversationId = '', mayCreateS
367
367
  `;
368
368
  }
369
369
 
370
+ /**
371
+ * The owner's plan-only switch, for every session: agents cannot take it out
372
+ * of plan mode while it is on (the owner still changes the mode freely).
373
+ */
374
+ export function renderPlanOnlyControlHtml({ conversationId = '', planOnly = false } = {}) {
375
+ return `
376
+ <div class="ctx-agent-sessions ctx-plan-only">
377
+ <label class="ctx-agent-sessions-row">
378
+ <input type="checkbox" id="ctx-plan-only" data-conversation-id="${escapeHtml(conversationId)}"${planOnly === true ? ' checked' : ''}>
379
+ <span>Plan only: agents can't take this session out of plan mode</span>
380
+ </label>
381
+ <div class="ctx-thinking-note">While this is on, an agent's message or settings change that names another mode is refused; you can still change the mode yourself. "Approve plan" runs one turn with write access and the session plans again after it. Agents may switch it off.</div>
382
+ </div>
383
+ `;
384
+ }
385
+
370
386
  // A model limit is a round figure: 1M rather than 1.0M.
371
387
  function formatModelLimit(value) {
372
388
  const n = toNullableNumber(value);
@@ -612,6 +612,15 @@ function steeringHoldForConversation(conversationId) {
612
612
  return { held: false, reason: null };
613
613
  }
614
614
 
615
+ // Plan mode on one side, the writing modes on the other (a turn's mode not yet
616
+ // known crosses nothing).
617
+ function modesCrossPlan(turnMode, composerMode) {
618
+ const turn = String(turnMode || '').trim().toLowerCase();
619
+ const composer = String(composerMode || '').trim().toLowerCase();
620
+ if (!turn || !composer) return false;
621
+ return (turn === 'plan') !== (composer === 'plan');
622
+ }
623
+
615
624
  function syncSendButtonState() {
616
625
  const btn = document.getElementById('send-btn');
617
626
  if (!btn) return;
@@ -631,6 +640,7 @@ function syncSendButtonState() {
631
640
  steeringSupported: conversationSupportsSteering(currentConvId),
632
641
  steeringHeld: steeringHold.held,
633
642
  steeringHoldReason: steeringHold.reason,
643
+ crossesPlanMode: modesCrossPlan(currentTurn?.mode, document.getElementById('mode-select')?.value),
634
644
  archived: conversations[String(currentConvId || '').trim()]?.archived === true,
635
645
  setupRequired: !String(currentConvId || '').trim() && isProviderSetupRequired(),
636
646
  });
@@ -1202,10 +1212,15 @@ function setConversationTurnState(conversationId, state = null) {
1202
1212
  syncSendButtonState();
1203
1213
  return;
1204
1214
  }
1215
+ const messageId = String(state.messageId || '').trim();
1216
+ const previous = activeTurnsByConversation.get(conversationKey);
1205
1217
  activeTurnsByConversation.set(conversationKey, {
1206
- messageId: String(state.messageId || '').trim(),
1218
+ messageId,
1207
1219
  status: String(state.status || 'processing').trim().toLowerCase() || 'processing',
1208
1220
  cancelRequested: state.cancelRequested === true,
1221
+ // The live turn's relay mode (the in-flight snapshot carries it); a status
1222
+ // update for the same turn keeps the one already known.
1223
+ mode: String(state.mode || (previous?.messageId === messageId ? previous.mode : '') || '').trim().toLowerCase(),
1209
1224
  });
1210
1225
  syncSendButtonState();
1211
1226
  }
@@ -2293,7 +2308,7 @@ export function restoreInFlightThinking(inFlight, autoScroll = true) {
2293
2308
  const liveStreamSeq = relayStreamStateByMessageId.get(messageId)?.seq || 0;
2294
2309
  const liveStreamText = relayStreamTextByMessageId.get(messageId);
2295
2310
  clearRelayStreamState();
2296
- setConversationTurnState(currentConvId, { messageId, status: 'processing' });
2311
+ setConversationTurnState(currentConvId, { messageId, status: 'processing', mode: inFlight?.mode });
2297
2312
  const activities = mergeRelayActivityTexts(
2298
2313
  relayActivities.get(messageId) || [],
2299
2314
  Array.isArray(inFlight.activities) ? inFlight.activities : [],