@oar-sh/oar 0.9.19 → 0.9.21

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 (190) 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 +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  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 +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -0,0 +1,430 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tokens per turn for every provider, and their daily sums: booked
5
+ * from the workers' reports (POST /api/turn-token-usage), read by the Tokens
6
+ * sections in Check Usage and the About panel.
7
+ *
8
+ * A turn's row (`turn_token_usage`, migration 0036) is keyed by turn, run
9
+ * and model. Its counts only rise: a later report of the same key keeps,
10
+ * field by field, the larger of the two (a field it leaves out keeps the
11
+ * earlier value), and only the rise goes into the day's row
12
+ * (`token_usage_days`, source `turns`). A retried or reordered report
13
+ * therefore books nothing twice. The day is the relay's local date (its
14
+ * time zone) of the turn's first report: the worker's `at`, or the relay's
15
+ * clock when that is missing or more than a day off. A later report of the
16
+ * same turn books into that day too.
17
+ *
18
+ * A Claude cloud report carries the session's running totals instead: the
19
+ * turn is what they add to the other turns already booked for the session
20
+ * and model (floored at 0), so a retry of the same turn stays the same.
21
+ *
22
+ * Day rows survive the chat: deleting it drops only its turn rows.
23
+ */
24
+
25
+ import {
26
+ TOKEN_USAGE_DAYS_TABLE,
27
+ TURN_TOKEN_USAGE_TABLE,
28
+ migrateTokenUsage,
29
+ tokenUsageProblem,
30
+ } from '../migrations/0036-token-usage.mjs';
31
+ import { TURN_TOKEN_FIELDS, addNullable } from '../../shared/turn-token-usage.mjs';
32
+
33
+ const COLUMN = Object.freeze({
34
+ inputTokens: 'input_tokens',
35
+ outputTokens: 'output_tokens',
36
+ cacheWriteTokens: 'cache_write_tokens',
37
+ cacheReadTokens: 'cache_read_tokens',
38
+ reasoningTokens: 'reasoning_tokens',
39
+ });
40
+ // The fields the views show and total; reasoning is a part of output.
41
+ const PRICED_FIELDS = Object.freeze(['inputTokens', 'outputTokens', 'cacheWriteTokens', 'cacheReadTokens']);
42
+ // How far a worker's `at` may be off the relay's clock before the relay's
43
+ // own time is taken instead.
44
+ const AT_SKEW_MS = 24 * 60 * 60 * 1000;
45
+
46
+ function dayFormatter(timeZone) {
47
+ try {
48
+ return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
49
+ } catch {
50
+ return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
51
+ }
52
+ }
53
+
54
+ function maxNullable(a, b) {
55
+ if (a == null) return b == null ? null : b;
56
+ if (b == null) return a;
57
+ return Math.max(a, b);
58
+ }
59
+
60
+ // What `merged` adds over `old`: null when nothing (the field stays as it is).
61
+ function rise(merged, old) {
62
+ if (merged == null) return null;
63
+ const by = merged - (old ?? 0);
64
+ return old == null || by > 0 ? by : null;
65
+ }
66
+
67
+ function emptyTotals() {
68
+ return {
69
+ ...Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, null])),
70
+ costUsd: null,
71
+ unknownCostTokens: 0,
72
+ turnCount: 0,
73
+ rows: 0,
74
+ // Per field, how many rows reported it: fewer than `rows` (and more than
75
+ // 0) means some turns left it out.
76
+ reported: Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, 0])),
77
+ };
78
+ }
79
+
80
+ function addDayRow(totals, row) {
81
+ for (const field of TURN_TOKEN_FIELDS) {
82
+ const value = row[COLUMN[field]];
83
+ totals[field] = addNullable(totals[field], value);
84
+ if (value != null) totals.reported[field] += 1;
85
+ }
86
+ totals.costUsd = addNullable(totals.costUsd, row.cost_usd);
87
+ totals.unknownCostTokens += Number(row.unknown_cost_tokens || 0);
88
+ totals.turnCount += Number(row.turn_count || 0);
89
+ totals.rows += 1;
90
+ }
91
+
92
+ /** The fields some rows of `totals` reported and others did not. */
93
+ export function partlyReportedFields(totals) {
94
+ return TURN_TOKEN_FIELDS.filter((field) => totals.reported[field] > 0 && totals.reported[field] < totals.rows);
95
+ }
96
+
97
+ function turnRowOut(row) {
98
+ return {
99
+ conversationId: row.conversation_id,
100
+ turnId: row.turn_id,
101
+ runId: row.run_id,
102
+ model: row.model,
103
+ provider: row.provider,
104
+ billing: row.billing,
105
+ nativeSessionId: row.native_session_id,
106
+ responseMessageId: row.response_message_id,
107
+ at: row.at,
108
+ day: row.day,
109
+ ...Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, row[COLUMN[field]]])),
110
+ costUsd: row.cost_usd,
111
+ costSource: row.cost_source,
112
+ costUnknown: row.cost_unknown === 1,
113
+ };
114
+ }
115
+
116
+ function sumTurnRows(rows) {
117
+ const totals = { ...Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, null])), costUsd: null };
118
+ for (const row of rows) {
119
+ for (const field of TURN_TOKEN_FIELDS) totals[field] = addNullable(totals[field], row[field]);
120
+ totals.costUsd = addNullable(totals.costUsd, row.costUsd);
121
+ }
122
+ return totals;
123
+ }
124
+
125
+ /**
126
+ * Drop the turn rows of chats removed for good, from the delete paths that
127
+ * run outside the conversation routes (the startup delete retry, the cache
128
+ * rebuild), inside their own transaction. The day rows stay. A database
129
+ * without the table (0036 not run yet) has nothing to drop.
130
+ */
131
+ export function forgetTurnTokenRows(db, conversationIds) {
132
+ const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(TURN_TOKEN_USAGE_TABLE);
133
+ if (!exists) return 0;
134
+ const remove = db.prepare(`DELETE FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ?`);
135
+ let changes = 0;
136
+ for (const id of conversationIds) changes += remove.run(String(id || '')).changes;
137
+ return changes;
138
+ }
139
+
140
+ export function createTokenUsageService({
141
+ db,
142
+ now = () => Date.now(),
143
+ // The relay's time zone (the month is a calendar month in relay local
144
+ // time, as on the Claude API card); a test names one.
145
+ timeZone = undefined,
146
+ logger = console,
147
+ } = {}) {
148
+ if (!db) throw new Error('createTokenUsageService requires db');
149
+ const formatDay = dayFormatter(timeZone);
150
+ const localDay = (ms) => formatDay.format(new Date(ms));
151
+
152
+ // The schema boot runs 0036; one that failed (a source table was missing)
153
+ // retries here, and until the tables are there every call is a no-op.
154
+ let stmts = null;
155
+ // Each distinct failure is logged once, not on every post and read.
156
+ const warned = new Set();
157
+ function ready() {
158
+ if (stmts) return stmts;
159
+ try {
160
+ migrateTokenUsage(db);
161
+ } catch (error) {
162
+ const text = `[token-usage] migration 0036 failed; retrying later: ${error?.message || error}`;
163
+ if (!warned.has(text)) {
164
+ warned.add(text);
165
+ logger?.warn?.(text);
166
+ }
167
+ }
168
+ if (tokenUsageProblem(db)) return null;
169
+ stmts = prepare();
170
+ return stmts;
171
+ }
172
+
173
+ function prepare() {
174
+ const fieldColumns = TURN_TOKEN_FIELDS.map((field) => COLUMN[field]);
175
+ return {
176
+ getTurnRow: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE turn_id = ? AND run_id = ? AND model = ?`),
177
+ upsertTurnRow: db.prepare(`
178
+ INSERT INTO ${TURN_TOKEN_USAGE_TABLE} (
179
+ conversation_id, turn_id, run_id, model, provider, billing, native_session_id, response_message_id,
180
+ at, day, ${fieldColumns.join(', ')}, cost_usd, cost_source, cost_unknown, updated_at
181
+ ) VALUES (
182
+ @conversationId, @turnId, @runId, @model, @provider, @billing, @nativeSessionId, @responseMessageId,
183
+ @at, @day, ${TURN_TOKEN_FIELDS.map((field) => `@${field}`).join(', ')}, @costUsd, @costSource, @costUnknown, @updatedAt
184
+ )
185
+ ON CONFLICT (turn_id, run_id, model) DO UPDATE SET
186
+ native_session_id = COALESCE(excluded.native_session_id, native_session_id),
187
+ response_message_id = COALESCE(excluded.response_message_id, response_message_id),
188
+ ${fieldColumns.map((column) => `${column} = excluded.${column}`).join(',\n ')},
189
+ cost_usd = excluded.cost_usd,
190
+ cost_source = excluded.cost_source,
191
+ cost_unknown = excluded.cost_unknown,
192
+ updated_at = excluded.updated_at
193
+ `),
194
+ // Null-aware: a field stays NULL until a turn reports it.
195
+ addDayRow: db.prepare(`
196
+ INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (
197
+ day, provider, billing, model, source, ${fieldColumns.join(', ')},
198
+ cost_usd, unknown_cost_tokens, turn_count, first_seen_at, last_seen_at, updated_at
199
+ ) VALUES (
200
+ @day, @provider, @billing, @model, 'turns', ${TURN_TOKEN_FIELDS.map((field) => `@${field}`).join(', ')},
201
+ @costUsd, @unknownCostTokens, @turnCount, @at, @at, @updatedAt
202
+ )
203
+ ON CONFLICT (day, provider, billing, model, source) DO UPDATE SET
204
+ ${fieldColumns.map((column) => `${column} = CASE WHEN excluded.${column} IS NULL THEN ${column} ELSE COALESCE(${column}, 0) + excluded.${column} END`).join(',\n ')},
205
+ cost_usd = CASE WHEN excluded.cost_usd IS NULL THEN cost_usd ELSE COALESCE(cost_usd, 0) + excluded.cost_usd END,
206
+ unknown_cost_tokens = unknown_cost_tokens + excluded.unknown_cost_tokens,
207
+ turn_count = turn_count + excluded.turn_count,
208
+ first_seen_at = MIN(first_seen_at, excluded.first_seen_at),
209
+ last_seen_at = MAX(last_seen_at, excluded.last_seen_at),
210
+ updated_at = excluded.updated_at
211
+ `),
212
+ // The other turns booked for a cloud session and model.
213
+ sumSessionRows: db.prepare(`
214
+ SELECT ${fieldColumns.map((column) => `SUM(${column}) AS ${column}`).join(', ')}, SUM(cost_usd) AS cost_usd
215
+ FROM ${TURN_TOKEN_USAGE_TABLE}
216
+ WHERE conversation_id = ? AND native_session_id = ? AND model = ? AND NOT (turn_id = ? AND run_id = ?)
217
+ `),
218
+ listDayRows: db.prepare(`SELECT * FROM ${TOKEN_USAGE_DAYS_TABLE} WHERE provider = ?`),
219
+ listAllDayRows: db.prepare(`SELECT * FROM ${TOKEN_USAGE_DAYS_TABLE}`),
220
+ listTurnRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE turn_id = ? ORDER BY at, run_id, model`),
221
+ listConversationRows: db.prepare(`SELECT * FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ? ORDER BY at, turn_id, run_id, model`),
222
+ deleteConversationRows: db.prepare(`DELETE FROM ${TURN_TOKEN_USAGE_TABLE} WHERE conversation_id = ?`),
223
+ };
224
+ }
225
+
226
+ function bookEntries(s, { conversationId, provider, billing, report, entries }) {
227
+ const nowMs = now();
228
+ const nowIso = new Date(nowMs).toISOString();
229
+ // The turn's time: the worker's, unless it is more than a day off the
230
+ // relay's clock.
231
+ const reportedMs = report.at ? Date.parse(report.at) : Number.NaN;
232
+ const atMs = Number.isFinite(reportedMs) && Math.abs(reportedMs - nowMs) <= AT_SKEW_MS ? reportedMs : nowMs;
233
+ const booked = [];
234
+ for (const entry of entries) {
235
+ const old = s.getTurnRow.get(report.turnId, report.runId, entry.model);
236
+ const merged = {};
237
+ const rises = {};
238
+ for (const field of TURN_TOKEN_FIELDS) {
239
+ const before = old ? old[COLUMN[field]] : null;
240
+ merged[field] = maxNullable(before, entry[field]);
241
+ rises[field] = rise(merged[field], before);
242
+ }
243
+ const costUsd = maxNullable(old ? old.cost_usd : null, entry.costUsd);
244
+ const costRise = rise(costUsd, old ? old.cost_usd : null);
245
+ const costUnknown = Boolean(old?.cost_unknown) || entry.costUnknown;
246
+ const day = old ? old.day : localDay(atMs);
247
+ const at = old ? old.at : new Date(atMs).toISOString();
248
+ // The row keeps the provider and billing of its first report.
249
+ const rowProvider = old ? old.provider : provider;
250
+ const rowBilling = old ? old.billing : billing;
251
+ s.upsertTurnRow.run({
252
+ conversationId,
253
+ turnId: report.turnId,
254
+ runId: report.runId,
255
+ model: entry.model,
256
+ provider: rowProvider,
257
+ billing: rowBilling,
258
+ nativeSessionId: report.nativeSessionId,
259
+ responseMessageId: report.responseMessageId,
260
+ at,
261
+ day,
262
+ ...merged,
263
+ costUsd,
264
+ costSource: entry.costSource || old?.cost_source || null,
265
+ costUnknown: costUnknown ? 1 : 0,
266
+ updatedAt: nowIso,
267
+ });
268
+ const changed = TURN_TOKEN_FIELDS.some((field) => rises[field] != null) || costRise != null;
269
+ if (!old || changed) {
270
+ const risenTokens = PRICED_FIELDS.reduce((sum, field) => sum + (rises[field] ?? 0), 0);
271
+ s.addDayRow.run({
272
+ day,
273
+ provider: rowProvider,
274
+ billing: rowBilling,
275
+ model: entry.model,
276
+ ...rises,
277
+ costUsd: costRise,
278
+ unknownCostTokens: costUnknown ? risenTokens : 0,
279
+ turnCount: old ? 0 : 1,
280
+ at,
281
+ updatedAt: nowIso,
282
+ });
283
+ }
284
+ booked.push({ model: entry.model, day, ...merged, costUsd, added: old ? changed : true });
285
+ }
286
+ return booked;
287
+ }
288
+
289
+ /**
290
+ * Book a turn's counts per model (`report.models`). `provider` and
291
+ * `billing` are the relay's: what the reporting worker is. Returns the
292
+ * booked rows, or null when the tables are not there.
293
+ */
294
+ function recordTurn({ conversationId, provider, billing = '', report }) {
295
+ const s = ready();
296
+ if (!s) return null;
297
+ return db.transaction(() => bookEntries(s, { conversationId, provider, billing, report, entries: report.models || [] }))();
298
+ }
299
+
300
+ /**
301
+ * Book a Claude cloud turn from the session's running totals per model
302
+ * (`report.sessionTotals`): the turn is what they add to the session's
303
+ * other booked turns, floored at 0.
304
+ */
305
+ function recordCloudSessionTotals({ conversationId, provider = 'claude-cloud', billing = '', report }) {
306
+ const s = ready();
307
+ if (!s) return null;
308
+ return db.transaction(() => {
309
+ const entries = (report.sessionTotals || []).map((entry) => {
310
+ const earlier = s.sumSessionRows.get(conversationId, report.nativeSessionId, entry.model, report.turnId, report.runId);
311
+ const turn = { ...entry };
312
+ for (const field of TURN_TOKEN_FIELDS) {
313
+ turn[field] = entry[field] == null ? null : Math.max(0, entry[field] - (earlier?.[COLUMN[field]] ?? 0));
314
+ }
315
+ turn.costUsd = entry.costUsd == null ? null : Math.max(0, entry.costUsd - (earlier?.cost_usd ?? 0));
316
+ return turn;
317
+ });
318
+ return bookEntries(s, { conversationId, provider, billing, report, entries });
319
+ })();
320
+ }
321
+
322
+ /**
323
+ * Today, this month and all time for a provider, per billing ('' first,
324
+ * then the own keys) and per model. `since` is the first day anything was
325
+ * counted, copied days included; `backfilled` says which periods hold days
326
+ * copied at the upgrade (whose input includes cache writes),
327
+ * `backfillThrough` the last such day and `firstTurnsDay` the first day a
328
+ * turn was booked.
329
+ */
330
+ function readPeriods({ provider, now: nowMs = now() } = {}) {
331
+ const s = ready();
332
+ if (!s) return null;
333
+ const today = localDay(nowMs);
334
+ const month = today.slice(0, 7);
335
+ const groups = new Map();
336
+ const rows = provider ? s.listDayRows.all(provider) : s.listAllDayRows.all();
337
+ for (const row of rows) {
338
+ const key = `${row.provider}\n${row.billing}`;
339
+ let group = groups.get(key);
340
+ if (!group) {
341
+ group = {
342
+ provider: row.provider,
343
+ billing: row.billing,
344
+ periods: { today: emptyTotals(), month: emptyTotals(), allTime: emptyTotals() },
345
+ models: new Map(),
346
+ since: null,
347
+ backfilled: { today: false, month: false, allTime: false },
348
+ backfillThrough: null,
349
+ firstTurnsDay: null,
350
+ };
351
+ groups.set(key, group);
352
+ }
353
+ let model = group.models.get(row.model);
354
+ if (!model) {
355
+ model = { model: row.model, today: emptyTotals(), month: emptyTotals(), allTime: emptyTotals() };
356
+ group.models.set(row.model, model);
357
+ }
358
+ const inPeriods = ['allTime'];
359
+ if (row.day.startsWith(`${month}-`)) inPeriods.push('month');
360
+ if (row.day === today) inPeriods.push('today');
361
+ for (const period of inPeriods) {
362
+ addDayRow(group.periods[period], row);
363
+ addDayRow(model[period], row);
364
+ if (row.source === 'backfill') group.backfilled[period] = true;
365
+ }
366
+ if (!group.since || row.day < group.since) group.since = row.day;
367
+ if (row.source === 'backfill' && (!group.backfillThrough || row.day > group.backfillThrough)) group.backfillThrough = row.day;
368
+ if (row.source === 'turns' && (!group.firstTurnsDay || row.day < group.firstTurnsDay)) group.firstTurnsDay = row.day;
369
+ }
370
+ return {
371
+ today,
372
+ month,
373
+ groups: [...groups.values()]
374
+ .sort((a, b) => a.provider.localeCompare(b.provider) || (a.billing ? 1 : 0) - (b.billing ? 1 : 0) || a.billing.localeCompare(b.billing))
375
+ .map((group) => ({
376
+ ...group,
377
+ models: [...group.models.values()].sort((a, b) => a.model.localeCompare(b.model)),
378
+ })),
379
+ };
380
+ }
381
+
382
+ /** A turn's rows (every run and model) and their sum; null when none. */
383
+ function readTurn(turnId) {
384
+ const s = ready();
385
+ if (!s) return null;
386
+ const rows = s.listTurnRows.all(String(turnId || '')).map(turnRowOut);
387
+ if (!rows.length) return null;
388
+ return { turnId: String(turnId), rows, totals: sumTurnRows(rows) };
389
+ }
390
+
391
+ /** A chat's rows and their sums, overall and per Claude Code session. */
392
+ function readConversation(conversationId) {
393
+ const s = ready();
394
+ if (!s) return null;
395
+ const rows = s.listConversationRows.all(String(conversationId || '')).map(turnRowOut);
396
+ const bySession = new Map();
397
+ for (const row of rows) {
398
+ const key = row.nativeSessionId || '';
399
+ if (!bySession.has(key)) bySession.set(key, []);
400
+ bySession.get(key).push(row);
401
+ }
402
+ return {
403
+ conversationId: String(conversationId),
404
+ rows,
405
+ totals: sumTurnRows(rows),
406
+ sessions: [...bySession.entries()].map(([nativeSessionId, sessionRows]) => ({
407
+ nativeSessionId: nativeSessionId || null,
408
+ totals: sumTurnRows(sessionRows),
409
+ })),
410
+ };
411
+ }
412
+
413
+ /** The chat is deleted: its turn rows go, the day rows stay. */
414
+ function forgetConversation(conversationId) {
415
+ const s = ready();
416
+ if (!s) return 0;
417
+ return s.deleteConversationRows.run(String(conversationId || '')).changes;
418
+ }
419
+
420
+ return {
421
+ recordTurn,
422
+ recordCloudSessionTotals,
423
+ readPeriods,
424
+ readTurn,
425
+ readConversation,
426
+ forgetConversation,
427
+ localDay: (ms = now()) => localDay(ms),
428
+ timeZone: formatDay.resolvedOptions().timeZone,
429
+ };
430
+ }
@@ -114,6 +114,9 @@ export function createUpdateInstallService({
114
114
  writeSetting,
115
115
  deleteSetting,
116
116
  requestRelayShutdown,
117
+ // The line on the next start's database backup (db-backup-service.mjs),
118
+ // said in the log before npm installs; null says nothing.
119
+ describeNextBackup = null,
117
120
  logger = console,
118
121
  } = {}) {
119
122
  const listeners = new Set();
@@ -206,6 +209,16 @@ export function createUpdateInstallService({
206
209
  lastError = null;
207
210
  deleteSetting(UPDATE_OUTCOME_SETTING_KEY);
208
211
  broadcast();
212
+ // Seen before the restart: whether the new version's first start backs
213
+ // up the database, or why not (a full disk).
214
+ if (describeNextBackup) {
215
+ try {
216
+ const line = describeNextBackup();
217
+ if (line) appendLog(`${line}\n`);
218
+ } catch (error) {
219
+ appendLog(`[backup] Whether the next start backs up the database could not be told (${error?.message || error}).\n`);
220
+ }
221
+ }
209
222
 
210
223
  // npm itself is a shim on Windows; spawn npm.cmd through a shell there
211
224
  // (args are fixed strings + a validated semver, so no injection surface).
@@ -18,6 +18,8 @@
18
18
 
19
19
  import { randomUUID } from 'node:crypto';
20
20
 
21
+ import { resumeRelayMode } from '../../shared/plan-only.mjs';
22
+ import { readPlanOnly } from './conversation-preferences-service.mjs';
21
23
  import {
22
24
  USAGE_LIMIT_CODE,
23
25
  USAGE_LIMIT_KIND,
@@ -300,7 +302,12 @@ export function createUsageLimitPauseService({
300
302
  const latestModel = stmts.getLatestConversationModel?.get?.(plan.conversationId)?.model || null;
301
303
  const model = String(queueRow?.model || '').trim() || latestModel || null;
302
304
  const modelVariant = String(queueRow?.model_variant_id || '').trim() || latestModel || model;
303
- const relayMode = String(queueRow?.relay_mode || '').trim() || 'agent';
305
+ // On a plan-only session only the "Approve plan" turn goes on in agent mode.
306
+ const relayMode = resumeRelayMode({
307
+ relayMode: queueRow?.relay_mode,
308
+ text: queueRow?.text,
309
+ planOnly: readPlanOnly(stmts.getConvAnyStatus?.get?.(plan.conversationId)),
310
+ });
304
311
  const owner = String(queueRow?.owner_sdk_session_id || '').trim() || null;
305
312
  stmts.insertMsg.run(
306
313
  messageId,