@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,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
+ }
@@ -1,4 +1,4 @@
1
- // ⋯ → About OAR: the menu entry's label and the modal (about-view.mjs builds
1
+ // ︙ → About OAR: the menu entry's label and the modal (about-view.mjs builds
2
2
  // its HTML from GET /api/about).
3
3
 
4
4
  import {
@@ -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,39 @@ 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
+
259
+ /**
260
+ * The running workers and how many still run the code before the last deploy
261
+ * (the relay recycles those once idle): "14, all on this build", "14, 3 on an
262
+ * older build", or the bare count when the relay cannot tell. Empty when no
263
+ * worker runs, so the row is left out.
264
+ */
265
+ export function workersBuildText(workers) {
266
+ const total = finite(workers?.total) || 0;
267
+ if (total <= 0) return '';
268
+ const count = formatCount(total);
269
+ // Number(null) is 0: an unknown count must not read as "all on this build".
270
+ const other = workers?.onOtherBuild === null || workers?.onOtherBuild === undefined ? null : finite(workers.onOtherBuild);
271
+ if (other === null) return count;
272
+ if (other <= 0) return total === 1 ? `${count}, on this build` : `${count}, all on this build`;
273
+ return `${count}, ${formatCount(other)} on an older build`;
274
+ }
275
+
176
276
  /**
177
277
  * @param {object} about the GET /api/about payload
178
278
  * @param {object} [options]
@@ -187,8 +287,10 @@ export function buildAboutModalHtml(about = {}, {
187
287
 
188
288
  const commit = String(about.commit || '').trim();
189
289
  const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
290
+ const workersText = workersBuildText(about.workers);
190
291
  sections.push(section('build', 'OAR', table([
191
292
  row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
293
+ ...(workersText ? [row('Workers', esc(workersText))] : []),
192
294
  ...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
193
295
  row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
194
296
  row('Uptime', esc(formatUptime(about.uptimeSeconds))),
@@ -217,13 +319,14 @@ export function buildAboutModalHtml(about = {}, {
217
319
  sections.push(section('usage', 'Usage', (usageIds.length
218
320
  ? usageIds.map((id) => `<div class="about-usage-provider">${esc(USAGE_LABELS[id])}</div>${table(usageRows(id, usage[id]))}`).join('')
219
321
  : 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.')));
322
+ + 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
323
 
222
324
  const relay = about.relay || {};
223
325
  sections.push(section('relay', 'Relay', table([
224
326
  row('Uptime', esc(formatUptime(relay.uptimeSeconds))),
225
- row('Database', esc(formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
327
+ row('Database', esc(relay.dbPath ? `${relay.dbPath} · ${formatBytes(relay.dbBytes)}` : formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
226
328
  row('Running workers', esc(formatCount(relay.runningWorkers))),
329
+ ...(about.backups ? [backupsRow(about.backups, formatDateTime)] : []),
227
330
  ])));
228
331
 
229
332
  sections.push(buildUpdateSection(about.update || {}, formatDateTime));
@@ -107,7 +107,7 @@ export async function confirmKillCurrentSession() {
107
107
  }
108
108
  }
109
109
 
110
- // ⋯ → Restart web relay first shows what the restart would wait for and
110
+ // ︙ → Restart web relay first shows what the restart would wait for and
111
111
  // leave running (relay-restart-ui.js); its buttons land in
112
112
  // confirmRestartWebRelay below or in confirmRestartRelayNow.
113
113
  export { openRestartRelayConfirmation };
@@ -982,7 +982,9 @@ export async function loadConversation(id, options = {}) {
982
982
  const aroundMessageId = String(options.aroundMessageId || '').trim();
983
983
  if (aroundMessageId) params.set('aroundMessageId', aroundMessageId);
984
984
  const query = params.toString();
985
- return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`);
985
+ // `onErrorResponse` tells a conversation the relay does not know (404) from a failed load.
986
+ const onErrorResponse = typeof options.onErrorResponse === 'function' ? options.onErrorResponse : undefined;
987
+ return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`, onErrorResponse ? { onErrorResponse } : undefined);
986
988
  }
987
989
 
988
990
  export async function refreshConversationHistory(id, options = {}) {
@@ -1247,7 +1249,7 @@ export async function createConversationShareLink(id) {
1247
1249
  });
1248
1250
  }
1249
1251
 
1250
- // Share management (Settings → Shared conversations, the ⋯ menu). These throw
1252
+ // Share management (Settings → Shared conversations, the ︙ menu). These throw
1251
1253
  // with the relay's reason, so the caller can say why an action failed.
1252
1254
  export async function listConversationShares() {
1253
1255
  return apiFetchOrThrow('/api/shares');
@@ -1709,13 +1711,14 @@ export async function loadRelayBoard(boardId) {
1709
1711
  return apiFetch(`/api/relay-board/${encodeURIComponent(id)}`);
1710
1712
  }
1711
1713
 
1712
- export async function submitRelayBoardAction(boardId, actionId) {
1714
+ export async function submitRelayBoardAction(boardId, actionId, { onErrorResponse } = {}) {
1713
1715
  const id = String(boardId || '').trim();
1714
1716
  const action = String(actionId || '').trim();
1715
1717
  if (!id || !action) return null;
1716
1718
  return apiFetch(`/api/relay-board/${encodeURIComponent(id)}/action`, {
1717
1719
  method: 'POST',
1718
1720
  body: JSON.stringify({ actionId: action }),
1721
+ onErrorResponse,
1719
1722
  });
1720
1723
  }
1721
1724
 
@@ -17,6 +17,7 @@ import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
17
  import { questionAcceptsFreeText } from './question-answer-rule.mjs';
18
18
  import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
19
19
  import { renderSessionAskCards } from './inbox-ui.js';
20
+ import { isConversationAutoOpenHeld } from './session-history-ui.js';
20
21
 
21
22
  let relayQuestionRenderHash = '';
22
23
  let renderedRelayQuestionIds = new Set();
@@ -124,13 +125,20 @@ export async function loadRelayQuestions(conversationId) {
124
125
  window.renderConvList?.();
125
126
  window.syncComposerButtonState?.();
126
127
 
127
- if (!conversationId && pending.length) {
128
+ // Nothing open: the latest question's conversation opens. Not from the
129
+ // Inbox or Status, which show nothing open too: they are being read. And
130
+ // in place of the empty screen, not as a step of its own in history. Nor
131
+ // after a link the relay did not know: its notice keeps the screen empty
132
+ // until the reader opens something.
133
+ const readingAView = document.body?.classList?.contains('inbox-view-active')
134
+ || document.body?.classList?.contains('status-view-active');
135
+ if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
128
136
  const latest = pending
129
137
  .slice()
130
138
  .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))[0];
131
139
  const targetConversationId = String(latest?.conversationId || '').trim();
132
140
  if (targetConversationId && targetConversationId !== currentConvId && conversations[targetConversationId]) {
133
- await window.openConversation?.(targetConversationId);
141
+ await window.openConversation?.(targetConversationId, { history: 'replace' });
134
142
  return;
135
143
  }
136
144
  }
@@ -171,10 +179,16 @@ export function updatePendingQuestionBanner() {
171
179
  banner.dataset.targetQuestionId = String(latest?.id || '').trim();
172
180
  const count = pending.length;
173
181
  const currentCount = currentConvId ? pending.filter((q) => q?.conversationId === currentConvId).length : 0;
174
- const convTitle = escHtml(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
175
- banner.innerHTML = currentCount > 0
176
- ? `❓ ${count} open question${count === 1 ? '' : 's'}`
177
- : `❓ ${count} open question${count === 1 ? '' : 's'} · latest in ${convTitle}`;
182
+ const rawTitle = String(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
183
+ const countText = `❓ ${count} open question${count === 1 ? '' : 's'}`;
184
+ const tailText = currentCount > 0 ? '' : ` · latest in ${rawTitle}`;
185
+ // The full text, and the compact chip "❓ N" the header fit switches to
186
+ // (data-compact) where the row is short. Either way the button's name and
187
+ // tooltip are the full text.
188
+ banner.innerHTML = `<span class="pending-question-full"><span class="pending-question-count">${escHtml(countText)}</span>${tailText ? `<span class="pending-question-tail">${escHtml(tailText)}</span>` : ''}</span>`
189
+ + `<span class="pending-question-compact" aria-hidden="true">❓ ${count}</span>`;
190
+ banner.title = `${countText}${tailText}`;
191
+ banner.setAttribute('aria-label', `${countText}${tailText}`);
178
192
  banner.classList.add('visible');
179
193
  banner.disabled = false;
180
194
  }
@@ -277,7 +277,7 @@ export function renderAttachmentPreview() {
277
277
  const annotatable = att.isImage && att.previewUrl && isAnnotatableImageType(att.type);
278
278
  const thumb = att.isImage && att.previewUrl
279
279
  ? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}"${annotatable ? ` onclick="openAnnotateEditorForPending(${idx})" title="Annotate ${escHtml(att.name)}" style="cursor: pointer;"` : ''}>`
280
- : `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
280
+ : `<div class="attachment-preview-meta" style="height:calc(88px * var(--ui-scale));display:flex;align-items:center;justify-content:center">📎</div>`;
281
281
  const annotateBadge = annotatable
282
282
  ? `<span class="attachment-preview-annotate" aria-hidden="true">🖍️</span>`
283
283
  : '';
@@ -9,7 +9,26 @@
9
9
 
10
10
  export const BACK_GUARD_FLOOR = 'oarBackGuardFloor';
11
11
  export const BACK_GUARD_APP = 'oarApp';
12
- const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
12
+ // Every entry the app writes carries its place in the stack, so a pop tells
13
+ // back from forward (session-history.mjs, viewer-history.mjs).
14
+ export const HISTORY_POSITION = 'oarPos';
15
+ // The visit's base (the floor, the phone's session list) is laid: a reload
16
+ // on any later entry must not lay it again.
17
+ export const VISIT_LAID = 'oarLaid';
18
+ // The place in the stack of the visit's lowest own entry (the phone's list,
19
+ // else the first session): back from above it stays in the app.
20
+ export const VISIT_BASE = 'oarBase';
21
+ export const FIRST_INTERACTION_EVENTS = Object.freeze(['pointerdown', 'keydown', 'touchstart']);
22
+
23
+ /** The marks every later entry of the visit carries over from the one below. */
24
+ export function inheritedVisitMarks(state) {
25
+ const marks = {};
26
+ if (!state || typeof state !== 'object') return marks;
27
+ if (state[BACK_GUARD_APP]) marks[BACK_GUARD_APP] = true;
28
+ if (state[VISIT_LAID]) marks[VISIT_LAID] = state[VISIT_LAID];
29
+ if (Number.isFinite(state[VISIT_BASE])) marks[VISIT_BASE] = state[VISIT_BASE];
30
+ return marks;
31
+ }
13
32
 
14
33
  /**
15
34
  * Lay the floor at the first touch or key, not at start-up: an entry a page
@@ -19,13 +38,13 @@ const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
19
38
  * interaction nothing a page does keeps back from leaving; from then on
20
39
  * the floor holds.
21
40
  */
22
- export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null } = {}) {
41
+ export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null, entryAgain = null } = {}) {
23
42
  if (!enabled || !history || !target) return null;
24
43
  const state = { guard: null };
25
44
  const arm = () => {
26
45
  for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
27
46
  if (state.guard) return;
28
- state.guard = installBackGuard({ history, enabled: true, onFloor });
47
+ state.guard = installBackGuard({ history, enabled: true, onFloor, entryAgain });
29
48
  target.addEventListener('popstate', () => state.guard.onPopState());
30
49
  };
31
50
  for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
@@ -38,8 +57,12 @@ export function armBackGuardOnFirstInteraction({ history, enabled = true, target
38
57
  * lets the next back leave unless the user touches the app in between:
39
58
  * one swallowed press, then the app closes — the pattern phones use, and
40
59
  * the moment to say "press back again to close".
60
+ *
61
+ * `entryAgain` names the entry pushed back above the floor: `{ state, url }`
62
+ * (the session list on a phone, else the session on screen). Without it a
63
+ * bare app entry goes back up.
41
64
  */
42
- export function installBackGuard({ history, enabled = true, onFloor = null } = {}) {
65
+ export function installBackGuard({ history, enabled = true, onFloor = null, entryAgain = null } = {}) {
43
66
  if (!enabled || !history) return null;
44
67
  const current = history.state && typeof history.state === 'object' ? history.state : {};
45
68
  // After a reload on the app's entry the floor already lies beneath it.
@@ -51,7 +74,10 @@ export function installBackGuard({ history, enabled = true, onFloor = null } = {
51
74
  /** Feed every popstate here. */
52
75
  onPopState() {
53
76
  if (!history.state?.[BACK_GUARD_FLOOR]) return;
54
- history.pushState({ [BACK_GUARD_APP]: true }, '');
77
+ const again = typeof entryAgain === 'function' ? entryAgain() : null;
78
+ if (again?.state && again.url) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '', again.url);
79
+ else if (again?.state) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '');
80
+ else history.pushState({ [BACK_GUARD_APP]: true }, '');
55
81
  onFloor?.();
56
82
  },
57
83
  };