@oar-sh/oar 0.9.19 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. package/shared/turn-token-usage.mjs +144 -0
@@ -28,6 +28,8 @@ import { createDetachedWorkerWatch } from './services/detached-worker-watch-serv
28
28
  import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
29
29
  import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
30
30
  import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
31
+ import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
32
+ import { carryPlanOnlyToCompacted, readPlanOnly } from './services/conversation-preferences-service.mjs';
31
33
  import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
32
34
  import { isRealPathWithinRoot } from './services/workspace-symlink-guard.mjs';
33
35
  import { applySchema } from './db-schema.mjs';
@@ -74,6 +76,7 @@ import { createClaudeApiSettingsService } from './services/claude-api-settings-s
74
76
  import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
75
77
  import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
76
78
  import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
79
+ import { createTokenUsageService } from './services/token-usage-service.mjs';
77
80
  import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
78
81
  import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
79
82
  import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
@@ -101,6 +104,7 @@ import { createRelayEventService } from './services/relay-event-service.mjs';
101
104
  import { createRelayScheduleService } from './services/relay-schedule-service.mjs';
102
105
  import { createRelayAskService } from './services/relay-ask-service.mjs';
103
106
  import { createRelayAskLinkService } from './services/relay-ask-link-service.mjs';
107
+ import { createRemoteInboxWatchService } from './services/remote-inbox-watch-service.mjs';
104
108
  import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
105
109
  import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
106
110
  import {
@@ -142,7 +146,10 @@ import { createCursorSessionRootResolver } from './services/cursor-session-root-
142
146
  import { createGitChangesService } from './services/git-changes-service.mjs';
143
147
  import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
144
148
  import { startRelayMemoryLog } from './relay-heap-guard.mjs';
145
- import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
149
+ import { TAKEOVER_PROGRESS_FILE, adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
150
+ import { checkDatabaseFile, resolveDatabaseFile, settleDatabaseFile } from './services/database-file.mjs';
151
+ import { describeBackups, describeNextBackup, maybeBackupBeforeStart, writeDbLastRun } from './services/db-backup-service.mjs';
152
+ import { resolveStateLayout } from './services/oar-state-layout.mjs';
146
153
  import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
147
154
  import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
148
155
  import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
@@ -219,7 +226,8 @@ import {
219
226
  resolveRelayRestartMaxWaitOverrideMs,
220
227
  RELAY_RESTART_MAX_WAIT_SETTING_KEY,
221
228
  } from './services/relay-restart-queue.mjs';
222
- import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
229
+ import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
230
+ import { createWorkerScope } from './services/worker-scope.mjs';
223
231
  import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
224
232
  import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
225
233
  import {
@@ -320,7 +328,9 @@ const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
320
328
  // for why the config can carry it (a dev checkout pointing at a release
321
329
  // install's data).
322
330
  const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
323
- const DB_PATH = path.join(DATA_DIR, 'copilot.db');
331
+ // DB_PATH (oar.db, or the name of installs from before 0.9.20) is
332
+ // resolved under the lock, once an earlier database may have been brought
333
+ // over: see resolveDatabaseFile below.
324
334
  const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
325
335
  // Uploads follow the data dir whenever the env var or config.json chose it.
326
336
  const UPLOAD_DIR = resolveUploadDir({ dataDir: DATA_DIR, env: process.env, serverDir: __dirname });
@@ -1441,7 +1451,16 @@ async function refreshGrokProviderModels() {
1441
1451
  }
1442
1452
  }
1443
1453
 
1444
- const DEFAULT_CONFIG = { authToken: '', port: 3333, pollIntervalMs: 3000, conversationSessionMode: 'isolated', localhostOnly: true };
1454
+ const DEFAULT_CONFIG = {
1455
+ authToken: '',
1456
+ port: 3333,
1457
+ pollIntervalMs: 3000,
1458
+ conversationSessionMode: 'isolated',
1459
+ localhostOnly: true,
1460
+ // A copy of the database at the first start of each new version, the last
1461
+ // `keep` kept (db-backup-service.mjs); `dir` (absolute) moves the folder.
1462
+ dbBackups: { enabled: true, keep: 2 },
1463
+ };
1445
1464
  // How long a turn may go without any sign of life from its worker before the
1446
1465
  // relay assumes the worker died. This is an inactivity window, not a cap on turn
1447
1466
  // length — see recoverStaleMessages.
@@ -1868,8 +1887,28 @@ try {
1868
1887
  console.error(`[server] ${error?.message || String(error)}`);
1869
1888
  process.exit(error?.code === 'ERELAYLOCKED' ? RELAY_STOP_EXIT_CODE : 1);
1870
1889
  }
1871
- // Before the database opens, under the lock (adoptEarlierDatabase); the
1872
- // result also feeds lostPackageDatabaseWarning.
1890
+ // Before the database opens, under the lock: both names there are settled
1891
+ // first (the earlier name's database, not newer, is set aside, else the
1892
+ // relay stops), then adoptEarlierDatabase, whose result also feeds
1893
+ // lostPackageDatabaseWarning.
1894
+ {
1895
+ let settled;
1896
+ try {
1897
+ settled = settleDatabaseFile({
1898
+ dataDir: DATA_DIR,
1899
+ Database,
1900
+ progressFile: path.join(DATA_DIR, TAKEOVER_PROGRESS_FILE),
1901
+ logger: console,
1902
+ });
1903
+ } catch (error) {
1904
+ settled = { status: 'refused', error: `[data] Not starting: the database files in ${DATA_DIR} could not be looked at (${error?.message || error}).` };
1905
+ }
1906
+ if (settled.status === 'refused') {
1907
+ console.error(settled.error);
1908
+ try { relaySingletonGuard.release(); } catch {}
1909
+ process.exit(RELAY_STOP_EXIT_CODE);
1910
+ }
1911
+ }
1873
1912
  let earlierDatabase;
1874
1913
  {
1875
1914
  try {
@@ -1892,6 +1931,21 @@ let earlierDatabase;
1892
1931
  process.exit(RELAY_STOP_EXIT_CODE);
1893
1932
  }
1894
1933
  }
1934
+ // Resolved after the adoption, which may have placed the database: `created`
1935
+ // only when neither name is there now.
1936
+ const databaseFile = resolveDatabaseFile(DATA_DIR);
1937
+ const DB_PATH = databaseFile.path;
1938
+ {
1939
+ // A dangling link, or an oar.db-wal/-shm without oar.db (a later rename to
1940
+ // oar.db would take its pages in), stops the start; other strays are said.
1941
+ const check = checkDatabaseFile(databaseFile);
1942
+ for (const warning of check.warnings) console.warn(warning);
1943
+ if (check.refusal) {
1944
+ console.error(check.refusal);
1945
+ try { relaySingletonGuard.release(); } catch {}
1946
+ process.exit(RELAY_STOP_EXIT_CODE);
1947
+ }
1948
+ }
1895
1949
 
1896
1950
  const MAX_REQUEUE_RETRIES = Number.isFinite(Number(config.maxRequeueRetries))
1897
1951
  ? Math.max(1, Number(config.maxRequeueRetries))
@@ -2737,7 +2791,7 @@ const configuredConversationSessionMode =
2737
2791
  updateModelCatalog({ models: [DEFAULT_MODEL], currentModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL, source: 'bootstrap' });
2738
2792
 
2739
2793
  // ─── SQLite Setup ─────────────────────────────────────────────────────────────
2740
- const databaseCreatedAtStart = !fs.existsSync(DB_PATH);
2794
+ const databaseCreatedAtStart = databaseFile.created;
2741
2795
  const db = new Database(DB_PATH);
2742
2796
  if (process.platform !== 'win32') {
2743
2797
  fs.chmodSync(DB_PATH, 0o600);
@@ -2750,11 +2804,39 @@ if (process.platform !== 'win32') {
2750
2804
  }
2751
2805
  }
2752
2806
 
2807
+ // Where the state root keeps backups (npm), or a checkout (server/backups).
2808
+ const STATE_LAYOUT = resolveStateLayout({ packageRoot: REPO_ROOT });
2809
+ // A copy of the database before this version's migrations, when another
2810
+ // version (on a checkout: another commit) ran on it last; before applySchema
2811
+ // and before reconcileUpdateAttempt, which removes the in-app update's
2812
+ // record that names the version before. Never stops the start.
2813
+ let dbBackupAtStart = null;
2814
+ try {
2815
+ dbBackupAtStart = await maybeBackupBeforeStart({
2816
+ db,
2817
+ Database,
2818
+ dbPath: DB_PATH,
2819
+ dataDir: DATA_DIR,
2820
+ databaseCreated: databaseCreatedAtStart,
2821
+ layout: STATE_LAYOUT,
2822
+ serverDir: __dirname,
2823
+ config,
2824
+ runningVersion: RUNNING_VERSION,
2825
+ runningCommit: BUILD_COMMIT,
2826
+ logger: console,
2827
+ });
2828
+ } catch (error) {
2829
+ console.warn(`[backup] failed: ${error?.message || error}; this start goes on without a backup of the version before.`);
2830
+ }
2831
+
2753
2832
  // Base DDL plus the column-backfill/upgrade migrations, extracted verbatim to
2754
2833
  // db-schema.mjs so tests can build the exact production schema. Runs the
2755
2834
  // CREATE TABLE IF NOT EXISTS block, the FTS setup, and every ALTER TABLE
2756
2835
  // upgrade in the original order.
2757
2836
  applySchema(db);
2837
+ // A database that had no app_settings table before applySchema (a new one):
2838
+ // the version that runs on it now.
2839
+ if (dbBackupAtStart?.deferredLastRun) writeDbLastRun(db, dbBackupAtStart.deferredLastRun);
2758
2840
 
2759
2841
  // ─── Prepared Statements ──────────────────────────────────────────────────────
2760
2842
  // Owns its own schema (plan-usage tables) and the derived Cursor spend
@@ -3245,11 +3327,16 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3245
3327
  if (!copilotSdkEngine) return openAiEnv;
3246
3328
  return applyCopilotSdkProviderEnvironment(openAiEnv, { enabled: true, model });
3247
3329
  }
3330
+ // Under a systemd user service, session workers start in scopes of their
3331
+ // own (worker-scope.mjs); asked once, at the first worker start.
3332
+ const workerScope = createWorkerScope({ hosting: detectRelayHosting(), log: (message) => console.log(`${runtimeLogPrefix()}${message}`) });
3248
3333
  const relayCliLauncherService = createRelayCliLauncherService({
3249
3334
  cwd: (targetSessionId) => resolveLaunchWorkspaceRootForSession(targetSessionId),
3250
3335
  env: sessionWorkerLaunchEnv,
3251
3336
  log: (message) => console.log(`${runtimeLogPrefix()}${message}`),
3337
+ workerScope,
3252
3338
  });
3339
+
3253
3340
  async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
3254
3341
  const normalizedTargetSessionId = String(targetSessionId || '').trim();
3255
3342
  if (!normalizedTargetSessionId) {
@@ -3281,6 +3368,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3281
3368
  const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
3282
3369
  const workerLaunchEnv = buildSessionWorkerLaunchEnvForSession(normalizedTargetSessionId);
3283
3370
  const launched = await launchSessionCli({
3371
+ workerScope,
3284
3372
  targetSessionId: normalizedTargetSessionId,
3285
3373
  cwd: resolvedWorkspaceRoot,
3286
3374
  env: workerLaunchEnv,
@@ -3359,6 +3447,7 @@ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
3359
3447
  });
3360
3448
  const updateInstallService = createUpdateInstallService({
3361
3449
  runningVersion: RUNNING_VERSION,
3450
+ describeNextBackup: () => describeNextBackup({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, dbPath: DB_PATH, config }),
3362
3451
  installMethod: INSTALL_METHOD,
3363
3452
  packageRoot: PACKAGE_ROOT,
3364
3453
  readSetting: readAppSettingValue,
@@ -3676,6 +3765,7 @@ function collectRestartPicture({ action = 'restart' } = {}) {
3676
3765
  restartPictureCollector = createRestartPictureCollector({
3677
3766
  db,
3678
3767
  backgroundWorkView,
3768
+ workerScopes: () => workerScope.inUse(),
3679
3769
  listHeldTasks: () => backgroundTaskStore.listRestored().map(({ key, task }) => ({
3680
3770
  conversationId: resolveConversationRecord({ conversationId: key, sdkSessionId: key })?.id || key,
3681
3771
  description: task?.description || task?.taskType || '',
@@ -4055,19 +4145,28 @@ function parseBoardContext(rawContext) {
4055
4145
 
4056
4146
  function formatRelayBoardRow(row) {
4057
4147
  if (!row) return null;
4148
+ const boardType = String(row.board_type || '').trim().toLowerCase() || 'generic';
4149
+ const status = String(row.status || 'pending').trim().toLowerCase() || 'pending';
4150
+ const planOnly = readPlanOnly(stmts.getConvAnyStatus.get(row.conversation_id));
4058
4151
  return {
4059
4152
  id: row.id,
4060
4153
  queueId: row.queue_id,
4061
4154
  conversationId: row.conversation_id,
4062
4155
  messageId: row.message_id,
4063
- boardType: String(row.board_type || '').trim().toLowerCase() || 'generic',
4156
+ boardType,
4064
4157
  mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
4065
4158
  title: String(row.title || '').trim(),
4066
4159
  body: String(row.body || '').trim(),
4067
- actions: parseBoardActions(row.actions_json),
4068
- recommendedAction: String(row.recommended_action || '').trim() || null,
4160
+ // A pending plan card stored before "Approve plan" existed shows it too.
4161
+ actions: planBoardShownActions({ boardType, status }, parseBoardActions(row.actions_json)),
4162
+ // On a plan-only session a pending plan card recommends "Approve plan".
4163
+ recommendedAction: (planOnly && status === 'pending' && boardType === 'plan_ready')
4164
+ ? PLAN_BOARD_APPROVE_ONLY
4165
+ : (String(row.recommended_action || '').trim() || null),
4166
+ // The UI asks before an action that leaves plan mode on such a session.
4167
+ planOnly,
4069
4168
  context: parseBoardContext(row.context_json),
4070
- status: String(row.status || 'pending').trim().toLowerCase() || 'pending',
4169
+ status,
4071
4170
  selectedAction: String(row.selected_action || '').trim() || null,
4072
4171
  actedAt: row.acted_at || null,
4073
4172
  createdAt: row.created_at,
@@ -5249,6 +5348,8 @@ function createCompactedConversation(sourceConversationId) {
5249
5348
  // The new conversation stays in the old one's session group, and heads
5250
5349
  // it if the old one did; the archived source stays a member.
5251
5350
  conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
5351
+ // A plan-only conversation's lock goes along, with its saved mode.
5352
+ carryPlanOnlyToCompacted({ db, stmts, source, targetConversationId, updatedAt: now });
5252
5353
  // A Claude chat keeps its billing (the subscription or the API key, a
5253
5354
  // pending swap's included): the new chat's first worker launches on it,
5254
5355
  // and it is never guessed again from a Claude model id. Other providers
@@ -5550,6 +5651,9 @@ const usageLimitPauseService = createUsageLimitPauseService({
5550
5651
  // workers' reports (services/claude-api-usage-service.mjs); the Claude API
5551
5652
  // card and its cap read it.
5552
5653
  const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console });
5654
+ // Tokens per turn and per day for every provider, booked from the
5655
+ // workers' reports; the Tokens sections in Check Usage and About read it.
5656
+ const tokenUsageService = createTokenUsageService({ db, logger: console });
5553
5657
  // This month's spend on the key against the cap, and the Check Usage card
5554
5658
  // (services/plan-usage-claude-api.mjs), over the same local days.
5555
5659
  const claudeApiPlanUsage = createClaudeApiPlanUsage({
@@ -7362,6 +7466,16 @@ const relayAskLinkService = createRelayAskLinkService({
7362
7466
  client: remoteRelayClient,
7363
7467
  logger: console,
7364
7468
  });
7469
+ // Paired relays' cards a turn waits on: the badge here stays current, and
7470
+ // one relay pushes a card its own relay reached no device for.
7471
+ const remoteInboxWatchService = createRemoteInboxWatchService({
7472
+ registry: remoteRelayRegistry,
7473
+ client: remoteRelayClient,
7474
+ push: pushDispatchService,
7475
+ hasSockets: () => io.of('/').sockets.size > 0,
7476
+ emit: (event) => io.emit(event),
7477
+ logger: console,
7478
+ });
7365
7479
  const remoteRelayPairing = createRemoteRelayPairing({
7366
7480
  registry: remoteRelayRegistry,
7367
7481
  client: remoteRelayClient,
@@ -7647,6 +7761,7 @@ const sharedRouteDeps = {
7647
7761
  usageLimitPauseService,
7648
7762
  claudeBillingSwitchService,
7649
7763
  claudeApiUsageService,
7764
+ tokenUsageService,
7650
7765
  claudeApiClaimCheck,
7651
7766
  onClaudeBillingFence: handleClaudeBillingFence,
7652
7767
  claudeApiPlanUsage,
@@ -7719,11 +7834,13 @@ registerRelayAskRoutes(app, {
7719
7834
  db,
7720
7835
  stmts,
7721
7836
  formatQuestionRow,
7837
+ formatRelayBoardRow,
7722
7838
  relayAskService,
7723
7839
  relayAskLinkService,
7724
7840
  remoteRelayInbound,
7725
7841
  remoteRelayRegistry,
7726
7842
  remoteRelayClient,
7843
+ questionNotice: (questionId) => pushDispatchService.questionNotice(questionId),
7727
7844
  });
7728
7845
  registerRelayRestartRoutes(app, {
7729
7846
  auth,
@@ -7758,6 +7875,7 @@ registerAboutRoutes(app, {
7758
7875
  isRelayCliOnline: () => runtimeState.cliOnline === true,
7759
7876
  listWorkers: () => sessionWorkerRegistry.listWorkers(),
7760
7877
  getUpdateState: () => buildUpdateStatePayload(),
7878
+ describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
7761
7879
  }),
7762
7880
  });
7763
7881
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
@@ -8109,6 +8227,8 @@ relayEventLinkService.start();
8109
8227
  relayScheduleService.start();
8110
8228
  // Asks on paired relays still open there: look for their answers again.
8111
8229
  relayAskLinkService.start();
8230
+ // Paired relays' Inboxes, every ~20 s.
8231
+ remoteInboxWatchService.start();
8112
8232
  runtimeTimers.pendingWorkerPrime = setInterval(() => {
8113
8233
  void primePendingSessionWorkers('maintenance');
8114
8234
  detachedWorkerWatch.sweep().catch((error) => {
@@ -8431,6 +8551,7 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
8431
8551
  try { relayEventLinkService.stop(); } catch {}
8432
8552
  try { relayScheduleService.stop(); } catch {}
8433
8553
  try { relayAskLinkService.stop(); } catch {}
8554
+ try { remoteInboxWatchService.stop(); } catch {}
8434
8555
  tmuxInspectorSocketService.stop();
8435
8556
  stopWorkspaceFileWatcher();
8436
8557
  sshTunnelManager.stop();
@@ -273,6 +273,42 @@ export function collectUsageTotals(db) {
273
273
  usage['claude-cloud'] = { costUsd: toAmount(row.cost), sessions: toCount(row.sessions) };
274
274
  }
275
275
  }
276
+ // Every provider's tokens per turn, all time (token_usage_days, migration
277
+ // 0036): fresh input, output, cache writes and cache reads apart, each
278
+ // null while no turn reported it, the first day, and whether days copied
279
+ // at the upgrade are in it. An own key's turns (Copilot) are kept apart
280
+ // from the plan's.
281
+ if (tableExists(db, 'token_usage_days')) {
282
+ const rows = db.prepare(`
283
+ SELECT provider, billing, SUM(input_tokens) AS input, SUM(output_tokens) AS output,
284
+ SUM(cache_write_tokens) AS cache_write, SUM(cache_read_tokens) AS cache_read, MIN(day) AS since,
285
+ MIN(CASE WHEN source = 'turns' THEN day END) AS first_turns_day,
286
+ MAX(CASE WHEN source = 'backfill' THEN 1 ELSE 0 END) AS backfilled
287
+ FROM token_usage_days
288
+ GROUP BY provider, billing
289
+ ORDER BY provider, billing
290
+ `).all();
291
+ const nullable = (value) => (value === null || value === undefined ? null : toAmount(value));
292
+ for (const row of rows) {
293
+ const tokens = {
294
+ inputTokens: nullable(row.input),
295
+ outputTokens: nullable(row.output),
296
+ cacheWriteTokens: nullable(row.cache_write),
297
+ cacheReadTokens: nullable(row.cache_read),
298
+ since: row.since || null,
299
+ // Days copied at the upgrade (0036) hold cache writes inside input;
300
+ // the first counted turn's day is where that stops.
301
+ backfilled: Number(row.backfilled) === 1,
302
+ firstTurnsDay: row.first_turns_day || null,
303
+ };
304
+ const entry = usage[row.provider] || (usage[row.provider] = {});
305
+ if (row.billing) {
306
+ (entry.ownKeyTokens || (entry.ownKeyTokens = [])).push({ billing: row.billing, ...tokens });
307
+ } else {
308
+ entry.tokens = tokens;
309
+ }
310
+ }
311
+ }
276
312
  return usage;
277
313
  }
278
314
 
@@ -310,10 +346,12 @@ async function fileSize(fsImpl, filePath) {
310
346
  * @param {() => object[]} [deps.listWorkers] the session worker registry's entries
311
347
  * @param {() => object|null} [deps.getUpdateState] buildUpdateStatePayload()
312
348
  * @param {object} [deps.sdkVersions] package version by provider id; read from node_modules when absent
349
+ * @param {() => object|null} [deps.describeBackups] the database backups (db-backup-service.mjs describeBackups)
313
350
  */
314
351
  export function createAboutService({
315
352
  db,
316
353
  dbPath = null,
354
+ describeBackups = null,
317
355
  version = '0.0.0',
318
356
  commit = null,
319
357
  installMethod = null,
@@ -451,14 +489,35 @@ export function createAboutService({
451
489
  usage: totals.usage,
452
490
  relay: {
453
491
  uptimeSeconds,
492
+ // The full path: which of the two database names this relay runs on.
493
+ dbPath: dbPath || null,
454
494
  dbBytes: dbFileBytes + walBytes,
455
495
  dbFileBytes,
456
496
  walBytes,
457
497
  runningWorkers: workers.total,
458
498
  },
459
499
  update: describeUpdate(),
500
+ backups: readBackups(),
460
501
  };
461
502
  }
462
503
 
504
+ // The last backup and how many are kept, for one row; never the listing.
505
+ function readBackups() {
506
+ if (!describeBackups) return null;
507
+ try {
508
+ const backups = describeBackups();
509
+ return {
510
+ enabled: backups.enabled,
511
+ keep: backups.keep,
512
+ dir: backups.dir,
513
+ problem: backups.problem,
514
+ kept: backups.own.length,
515
+ lastResult: backups.lastResult,
516
+ };
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+
463
522
  return { describe };
464
523
  }
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ import { forgetTurnTokenRows } from './token-usage-service.mjs';
4
+
3
5
  function normalizeId(value) {
4
6
  return String(value || '').trim();
5
7
  }
@@ -99,6 +101,8 @@ export function createCacheRebuildService({
99
101
  db.prepare(`DELETE FROM queue WHERE conversation_id = ?`).run(sid);
100
102
  db.prepare(`DELETE FROM messages WHERE conversation_id = ?`).run(sid);
101
103
  db.prepare(`DELETE FROM runtime_sessions WHERE conversation_id = ?`).run(sid);
104
+ // Its turns' tokens go too; the day sums stay.
105
+ forgetTurnTokenRows(db, [sid]);
102
106
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(sid);
103
107
  })();
104
108
  closePreviews(sid);
@@ -164,6 +168,9 @@ export function createCacheRebuildService({
164
168
  db.prepare(`DELETE FROM queue`).run();
165
169
  db.prepare(`DELETE FROM messages`).run();
166
170
  db.prepare(`DELETE FROM runtime_sessions`).run();
171
+ // The turn tokens of the chats removed for good; a hand-titled chat
172
+ // stays, and keeps them.
173
+ forgetTurnTokenRows(db, db.prepare(`SELECT id FROM conversations WHERE status = 'deleted' OR title_source != 'manual'`).all().map((row) => row.id));
167
174
  db.prepare(`DELETE FROM conversations WHERE status = 'deleted' OR title_source != 'manual'`).run();
168
175
  for (const conversationId of manualTitleConversations) {
169
176
  db.prepare(`
@@ -44,6 +44,9 @@ export function persistConversationPreferences({
44
44
  // The owner's "agents in this conversation may start sessions" (a boolean;
45
45
  // `undefined` leaves it alone). The route decides who may write it.
46
46
  mayCreateSessions = undefined,
47
+ // Plan-only: agents cannot take the session out of plan mode (a boolean;
48
+ // `undefined` leaves it alone). The routes decide who may write it.
49
+ planOnly = undefined,
47
50
  updatedAt = new Date().toISOString(),
48
51
  createIfMissing = false,
49
52
  createTitle = 'Session',
@@ -60,6 +63,7 @@ export function persistConversationPreferences({
60
63
  const writesThinkingDisplay = thinkingDisplay !== undefined;
61
64
  const display = writesThinkingDisplay ? parseThinkingDisplay(thinkingDisplay) : null;
62
65
  const writesMayCreateSessions = mayCreateSessions !== undefined;
66
+ const writesPlanOnly = planOnly !== undefined;
63
67
  if (!db || !stmts || !convId || !mode) {
64
68
  return {
65
69
  ok: false,
@@ -71,6 +75,7 @@ export function persistConversationPreferences({
71
75
  thinkingEnabled: DEFAULT_THINKING_ENABLED,
72
76
  thinkingDisplay: DEFAULT_THINKING_DISPLAY,
73
77
  mayCreateSessions: false,
78
+ planOnly: false,
74
79
  updatedAt,
75
80
  };
76
81
  }
@@ -108,6 +113,10 @@ export function persistConversationPreferences({
108
113
  extraSets.push('may_create_sessions = ?');
109
114
  extraValues.push(mayCreateSessions === true ? 1 : 0);
110
115
  }
116
+ if (writesPlanOnly) {
117
+ extraSets.push('plan_only = ?');
118
+ extraValues.push(planOnly === true ? 1 : 0);
119
+ }
111
120
  try {
112
121
  if (extraSets.length) {
113
122
  db.prepare(`
@@ -148,6 +157,9 @@ export function persistConversationPreferences({
148
157
  mayCreateSessions: writesMayCreateSessions
149
158
  ? mayCreateSessions === true
150
159
  : readMayCreateSessions(existing),
160
+ planOnly: writesPlanOnly
161
+ ? planOnly === true
162
+ : readPlanOnly(existing),
151
163
  updatedAt,
152
164
  };
153
165
  });
@@ -159,6 +171,25 @@ export function readMayCreateSessions(row) {
159
171
  return Number(row?.may_create_sessions) === 1;
160
172
  }
161
173
 
174
+ /** Whether a conversations row is plan-only (migration 0035; NULL is not). */
175
+ export function readPlanOnly(row) {
176
+ return Number(row?.plan_only) === 1;
177
+ }
178
+
179
+ /**
180
+ * A compaction's new conversation keeps a plan-only source's lock and the
181
+ * mode it is saved in, so the work goes on as locked as it was. Nothing for
182
+ * any other source. Returns whether it carried the lock.
183
+ */
184
+ export function carryPlanOnlyToCompacted({ db, stmts, source, targetConversationId, updatedAt = new Date().toISOString() } = {}) {
185
+ if (!db || !stmts?.conversationsHavePlanOnly || !readPlanOnly(source)) return false;
186
+ const target = String(targetConversationId || '').trim();
187
+ if (!target) return false;
188
+ const mode = String(source?.preferred_relay_mode || '').trim() || 'plan';
189
+ db.prepare(`UPDATE conversations SET plan_only = 1, preferred_relay_mode = ?, updated_at = ? WHERE id = ?`).run(mode, updatedAt, target);
190
+ return true;
191
+ }
192
+
162
193
  export function persistConversationModelPreference({
163
194
  db,
164
195
  stmts,