@oar-sh/oar 0.9.18 → 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 (120) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +13 -1
  3. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  4. package/CHANGELOG.md +359 -0
  5. package/README.md +10 -10
  6. package/bin/oar.js +391 -97
  7. package/package.json +4 -2
  8. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -2
  11. package/server/claude-worker/claude-session-process.mjs +3 -0
  12. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  13. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  14. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  15. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  16. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  17. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  18. package/server/db-schema.mjs +18 -0
  19. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  20. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  21. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  22. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  23. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  24. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  25. package/server/migrations/0005-remote-relays.mjs +5 -7
  26. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  27. package/server/migrations/0007-claude-cloud.mjs +5 -7
  28. package/server/migrations/0008-message-pins.mjs +5 -7
  29. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  30. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  31. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  33. package/server/migrations/0013-previews.mjs +5 -7
  34. package/server/migrations/0014-conversation-groups.mjs +5 -7
  35. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  36. package/server/migrations/0016-share-access-log.mjs +5 -7
  37. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  38. package/server/migrations/0018-background-task-rows.mjs +5 -7
  39. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  40. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  41. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  42. package/server/migrations/0022-relay-schedules.mjs +5 -7
  43. package/server/migrations/0023-relay-asks.mjs +5 -7
  44. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  45. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  46. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  47. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  48. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  49. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  50. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  51. package/server/migrations/0031-queue-provider.mjs +5 -7
  52. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  53. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  54. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  55. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  56. package/server/migrations/0036-token-usage.mjs +267 -0
  57. package/server/public/app/about-view.mjs +87 -3
  58. package/server/public/app/api-client.js +2 -1
  59. package/server/public/app/bootstrap.js +55 -2
  60. package/server/public/app/composer-control-state.mjs +12 -2
  61. package/server/public/app/context-usage-view.mjs +16 -0
  62. package/server/public/app/conversation-view.js +17 -2
  63. package/server/public/app/inbox-ui.js +32 -0
  64. package/server/public/app/inbox-view.mjs +141 -5
  65. package/server/public/app/plan-board-shared.mjs +49 -0
  66. package/server/public/app/plan-usage-view.mjs +1 -1
  67. package/server/public/app/relay-board-inline.mjs +4 -1
  68. package/server/public/app/relay-board-view.js +38 -10
  69. package/server/public/app/relay-event-view.mjs +4 -0
  70. package/server/public/app/socket-handlers.js +10 -2
  71. package/server/public/index.html +14 -0
  72. package/server/public/sw.js +8 -0
  73. package/server/relay-heap-guard.mjs +351 -0
  74. package/server/relay-self-restart.mjs +116 -9
  75. package/server/relay-tools.md +5 -4
  76. package/server/repositories/message-repository.mjs +31 -1
  77. package/server/repositories/remote-relay-repository.mjs +10 -0
  78. package/server/repositories/session-repository.mjs +6 -1
  79. package/server/routes/messages-routes.mjs +120 -4
  80. package/server/routes/relay-ask-routes.mjs +138 -8
  81. package/server/routes/relay-board-routes.mjs +55 -40
  82. package/server/routes/sessions-routes.mjs +141 -31
  83. package/server/server-runtime.mjs +142 -11
  84. package/server/services/about-service.mjs +59 -0
  85. package/server/services/cache-rebuild-service.mjs +7 -0
  86. package/server/services/conversation-preferences-service.mjs +31 -0
  87. package/server/services/database-file.mjs +413 -0
  88. package/server/services/db-backup-service.mjs +809 -0
  89. package/server/services/deepseek-usage-import.mjs +6 -9
  90. package/server/services/delete-archive-service.mjs +3 -0
  91. package/server/services/earlier-database-service.mjs +69 -126
  92. package/server/services/oar-cli-helpers.mjs +214 -12
  93. package/server/services/oar-cli-menu.mjs +1 -0
  94. package/server/services/oar-state-migration-service.mjs +33 -13
  95. package/server/services/package-data-rescue.mjs +4 -2
  96. package/server/services/plan-usage-claude-api.mjs +14 -15
  97. package/server/services/plan-usage-contract.mjs +6 -2
  98. package/server/services/plan-usage-deepseek.mjs +1 -1
  99. package/server/services/plan-usage-tokens.mjs +248 -0
  100. package/server/services/push-dispatch-service.mjs +134 -9
  101. package/server/services/relay-cli-launcher-service.mjs +3 -0
  102. package/server/services/relay-event-service.mjs +30 -0
  103. package/server/services/relay-question-context.mjs +11 -0
  104. package/server/services/relay-restart-picture.mjs +67 -22
  105. package/server/services/remote-inbox-watch-service.mjs +204 -0
  106. package/server/services/remote-relay-caller-context.mjs +80 -7
  107. package/server/services/remote-relay-dispatcher.mjs +68 -8
  108. package/server/services/restart-resume-service.mjs +5 -2
  109. package/server/services/session-worker-launch-service.mjs +12 -3
  110. package/server/services/session-worker-stop-service.mjs +27 -6
  111. package/server/services/token-usage-service.mjs +430 -0
  112. package/server/services/update-install-service.mjs +13 -0
  113. package/server/services/usage-limit-pause-service.mjs +8 -1
  114. package/server/services/worker-scope.mjs +77 -0
  115. package/shared/plan-board-actions.mjs +130 -0
  116. package/shared/plan-only.mjs +43 -0
  117. package/shared/relay-asks.mjs +3 -0
  118. package/shared/relay-events.mjs +38 -4
  119. package/shared/remote-relay-contract.mjs +98 -21
  120. package/shared/turn-token-usage.mjs +144 -0
@@ -39,6 +39,8 @@
39
39
  */
40
40
 
41
41
  import { randomUUID } from 'node:crypto';
42
+ import { resumeRelayMode } from '../../shared/plan-only.mjs';
43
+ import { readPlanOnly } from './conversation-preferences-service.mjs';
42
44
 
43
45
  export const RESTART_RESUME_SETTING_KEY = 'restart_resume_notes';
44
46
  export const RESTART_RESUME_KIND = 'relay-restart-resume';
@@ -91,7 +93,7 @@ export function createRestartResumeService({
91
93
  if (!db || !stmts) throw new Error('createRestartResumeService requires db and stmts');
92
94
  const listProcessing = db.prepare(`
93
95
  SELECT id, conversation_id, model, model_variant_id, reasoning_effort, context_tier, relay_mode,
94
- owner_sdk_session_id, runtime_session_id
96
+ owner_sdk_session_id, runtime_session_id, text
95
97
  FROM queue WHERE status = 'processing'
96
98
  `);
97
99
  const statusOf = db.prepare(`SELECT status FROM queue WHERE id = ?`);
@@ -212,7 +214,8 @@ export function createRestartResumeService({
212
214
  // The model and mode the interrupted turn ran with.
213
215
  const model = text(cutRow?.model) || text(latestModel) || text(runtimeSession?.provider_model) || text(runtimeSession?.model) || null;
214
216
  const modelVariant = text(cutRow?.model_variant_id) || model;
215
- const relayMode = text(cutRow?.relay_mode) || 'agent';
217
+ // On a plan-only session only the "Approve plan" turn goes on in agent mode.
218
+ const relayMode = resumeRelayMode({ relayMode: cutRow?.relay_mode, text: cutRow?.text, planOnly: readPlanOnly(conversation) });
216
219
  const owner = text(cutRow?.owner_sdk_session_id) || text(runtimeSession?.sdk_session_id) || null;
217
220
  const write = db.transaction(() => {
218
221
  stmts.insertMsg.run(
@@ -1126,6 +1126,9 @@ export async function launchSessionCli({
1126
1126
  locateExecutableImpl = locateExecutable,
1127
1127
  // Asked after a failed spawn whether its folder is gone (the error, or null).
1128
1128
  checkWorkingDirectoryImpl = missingWorkingDirectoryError,
1129
+ // Under a systemd user service: puts the worker in a scope of its own
1130
+ // (worker-scope.mjs). None: the worker starts as it is.
1131
+ workerScope = null,
1129
1132
  } = {}) {
1130
1133
  const target = String(targetSessionId || '').trim();
1131
1134
  if (!target) throw new Error('missing-target-session-id');
@@ -1237,7 +1240,7 @@ export async function launchSessionCli({
1237
1240
  ? prepareWorkerLogFile(target, launchSessionEnv)
1238
1241
  : null;
1239
1242
  try {
1240
- execFileSyncImpl('tmux', [
1243
+ const tmuxArgs = [
1241
1244
  'new-session',
1242
1245
  '-d',
1243
1246
  '-s',
@@ -1251,7 +1254,10 @@ export async function launchSessionCli({
1251
1254
  workerLogPath: tmuxWorkerLogPath || '',
1252
1255
  extraCliArgs: extensionCliArgs,
1253
1256
  }),
1254
- ], {
1257
+ ];
1258
+ // A tmux server this call starts is in the worker's own scope.
1259
+ const tmuxLaunch = workerScope ? workerScope.wrap('tmux', tmuxArgs) : { command: 'tmux', args: tmuxArgs };
1260
+ execFileSyncImpl(tmuxLaunch.command, tmuxLaunch.args, {
1255
1261
  env: tmuxEnv,
1256
1262
  stdio: ['ignore', 'ignore', 'ignore'],
1257
1263
  });
@@ -1351,7 +1357,10 @@ export async function launchSessionCli({
1351
1357
  }
1352
1358
  }
1353
1359
  }
1354
- const child = spawnImpl(spawnCommand, spawnArgs, {
1360
+ // POSIX: in a scope of its own under a systemd user service; the process
1361
+ // id is still the worker's.
1362
+ const detachedLaunch = platform !== 'win32' && workerScope ? workerScope.wrap(spawnCommand, spawnArgs) : { command: spawnCommand, args: spawnArgs };
1363
+ const child = spawnImpl(detachedLaunch.command, detachedLaunch.args, {
1355
1364
  cwd: launchProcessCwd,
1356
1365
  env: launchSessionEnv,
1357
1366
  detached: true,
@@ -22,12 +22,17 @@ function toPidList(rows, extraPid) {
22
22
 
23
23
  // The process list is read off the relay's thread, fresh (a stop must see
24
24
  // the children started a moment ago). Throws when the list could not be
25
- // read: the caller must not pretend it stopped what it could not see.
26
- async function enumeratePids({ sdkSessionId, worker, processInspector, platform }) {
25
+ // read: the caller must not pretend it stopped what it could not see. The
26
+ // registry's pid counts only while that process is alive: a worker that is
27
+ // gone is stopped already (on Windows its stop call alone could fail).
28
+ async function enumeratePids({ sdkSessionId, worker, processInspector, platform, isPidAliveImpl }) {
27
29
  const rows = platform === 'win32'
28
30
  ? await processInspector?.findWindowsProcessTreeForSessionAsync?.(sdkSessionId, { fresh: true })
29
31
  : await processInspector?.findProcessesForSessionAsync?.(sdkSessionId, { fresh: true });
30
- return toPidList(rows || [], worker?.pid);
32
+ const listed = toPidList(rows || []);
33
+ const workerPid = Number(worker?.pid);
34
+ const workerAlive = Number.isInteger(workerPid) && workerPid > 0 && (listed.includes(workerPid) || isPidAliveImpl(workerPid));
35
+ return workerAlive ? toPidList(rows || [], workerPid) : listed;
31
36
  }
32
37
 
33
38
  async function stopWindowsPids(processInspector, pids) {
@@ -36,13 +41,29 @@ async function stopWindowsPids(processInspector, pids) {
36
41
 
37
42
  export const PROCESS_LIST_UNREADABLE = 'process-list-unreadable';
38
43
 
44
+ /**
45
+ * Whether `pid` is a live process: signal 0 reaches it. EPERM means it
46
+ * exists but belongs to someone else: alive. The default for every stop
47
+ * that is given no check of its own.
48
+ */
49
+ export function isProcessAlive(pid, { killImpl = (target, signal) => process.kill(target, signal) } = {}) {
50
+ const value = Number(pid);
51
+ if (!Number.isInteger(value) || value <= 0) return false;
52
+ try {
53
+ killImpl(value, 0);
54
+ return true;
55
+ } catch (error) {
56
+ return String(error?.code || '').toUpperCase() === 'EPERM';
57
+ }
58
+ }
59
+
39
60
  export async function stopSessionWorkerProcesses({
40
61
  sdkSessionId,
41
62
  worker = null,
42
63
  processInspector = null,
43
64
  platform = process.platform,
44
65
  killImpl = (pid, signal) => process.kill(pid, signal),
45
- isPidAliveImpl = () => false,
66
+ isPidAliveImpl = isProcessAlive,
46
67
  killTmuxSessionImpl = null,
47
68
  sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
48
69
  nowMs = Date.now,
@@ -67,7 +88,7 @@ export async function stopSessionWorkerProcesses({
67
88
 
68
89
  let pids;
69
90
  try {
70
- pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
91
+ pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
71
92
  } catch (error) {
72
93
  // Nothing is stopped blind: the known pid alone would leave the tree
73
94
  // running, and the caller would relaunch next to it.
@@ -115,7 +136,7 @@ export async function stopSessionWorkerProcesses({
115
136
  try {
116
137
  if (platform === 'win32') {
117
138
  // Re-enumerate: children spawned after the first snapshot are invisible to it.
118
- const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
139
+ const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
119
140
  escalationTargets = [...new Set([...remainingPids, ...rediscovered.filter((pid) => isPidAliveImpl(pid))])];
120
141
  await stopWindowsPids(processInspector, escalationTargets);
121
142
  } else {
@@ -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,