@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
@@ -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 {
@@ -141,7 +145,11 @@ import { createClaudeSessionRootResolver } from './services/claude-session-root-
141
145
  import { createCursorSessionRootResolver } from './services/cursor-session-root-service.mjs';
142
146
  import { createGitChangesService } from './services/git-changes-service.mjs';
143
147
  import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
144
- import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
148
+ import { startRelayMemoryLog } from './relay-heap-guard.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';
145
153
  import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
146
154
  import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
147
155
  import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
@@ -218,7 +226,8 @@ import {
218
226
  resolveRelayRestartMaxWaitOverrideMs,
219
227
  RELAY_RESTART_MAX_WAIT_SETTING_KEY,
220
228
  } from './services/relay-restart-queue.mjs';
221
- 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';
222
231
  import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
223
232
  import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
224
233
  import {
@@ -319,7 +328,9 @@ const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
319
328
  // for why the config can carry it (a dev checkout pointing at a release
320
329
  // install's data).
321
330
  const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
322
- 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.
323
334
  const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
324
335
  // Uploads follow the data dir whenever the env var or config.json chose it.
325
336
  const UPLOAD_DIR = resolveUploadDir({ dataDir: DATA_DIR, env: process.env, serverDir: __dirname });
@@ -1440,7 +1451,16 @@ async function refreshGrokProviderModels() {
1440
1451
  }
1441
1452
  }
1442
1453
 
1443
- 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
+ };
1444
1464
  // How long a turn may go without any sign of life from its worker before the
1445
1465
  // relay assumes the worker died. This is an inactivity window, not a cap on turn
1446
1466
  // length — see recoverStaleMessages.
@@ -1867,8 +1887,28 @@ try {
1867
1887
  console.error(`[server] ${error?.message || String(error)}`);
1868
1888
  process.exit(error?.code === 'ERELAYLOCKED' ? RELAY_STOP_EXIT_CODE : 1);
1869
1889
  }
1870
- // Before the database opens, under the lock (adoptEarlierDatabase); the
1871
- // 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
+ }
1872
1912
  let earlierDatabase;
1873
1913
  {
1874
1914
  try {
@@ -1891,6 +1931,21 @@ let earlierDatabase;
1891
1931
  process.exit(RELAY_STOP_EXIT_CODE);
1892
1932
  }
1893
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
+ }
1894
1949
 
1895
1950
  const MAX_REQUEUE_RETRIES = Number.isFinite(Number(config.maxRequeueRetries))
1896
1951
  ? Math.max(1, Number(config.maxRequeueRetries))
@@ -1949,6 +2004,8 @@ const runtimeTimers = {
1949
2004
  sharedViewerPrune: null,
1950
2005
  restoredTaskSettle: null,
1951
2006
  claudeApiCapClock: null,
2007
+ memoryLog: null,
2008
+ memoryWatch: null,
1952
2009
  };
1953
2010
 
1954
2011
  function normalizeRemotePath(value) {
@@ -2734,7 +2791,7 @@ const configuredConversationSessionMode =
2734
2791
  updateModelCatalog({ models: [DEFAULT_MODEL], currentModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL, source: 'bootstrap' });
2735
2792
 
2736
2793
  // ─── SQLite Setup ─────────────────────────────────────────────────────────────
2737
- const databaseCreatedAtStart = !fs.existsSync(DB_PATH);
2794
+ const databaseCreatedAtStart = databaseFile.created;
2738
2795
  const db = new Database(DB_PATH);
2739
2796
  if (process.platform !== 'win32') {
2740
2797
  fs.chmodSync(DB_PATH, 0o600);
@@ -2747,11 +2804,39 @@ if (process.platform !== 'win32') {
2747
2804
  }
2748
2805
  }
2749
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
+
2750
2832
  // Base DDL plus the column-backfill/upgrade migrations, extracted verbatim to
2751
2833
  // db-schema.mjs so tests can build the exact production schema. Runs the
2752
2834
  // CREATE TABLE IF NOT EXISTS block, the FTS setup, and every ALTER TABLE
2753
2835
  // upgrade in the original order.
2754
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);
2755
2840
 
2756
2841
  // ─── Prepared Statements ──────────────────────────────────────────────────────
2757
2842
  // Owns its own schema (plan-usage tables) and the derived Cursor spend
@@ -3242,11 +3327,16 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
3242
3327
  if (!copilotSdkEngine) return openAiEnv;
3243
3328
  return applyCopilotSdkProviderEnvironment(openAiEnv, { enabled: true, model });
3244
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}`) });
3245
3333
  const relayCliLauncherService = createRelayCliLauncherService({
3246
3334
  cwd: (targetSessionId) => resolveLaunchWorkspaceRootForSession(targetSessionId),
3247
3335
  env: sessionWorkerLaunchEnv,
3248
3336
  log: (message) => console.log(`${runtimeLogPrefix()}${message}`),
3337
+ workerScope,
3249
3338
  });
3339
+
3250
3340
  async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
3251
3341
  const normalizedTargetSessionId = String(targetSessionId || '').trim();
3252
3342
  if (!normalizedTargetSessionId) {
@@ -3278,6 +3368,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
3278
3368
  const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
3279
3369
  const workerLaunchEnv = buildSessionWorkerLaunchEnvForSession(normalizedTargetSessionId);
3280
3370
  const launched = await launchSessionCli({
3371
+ workerScope,
3281
3372
  targetSessionId: normalizedTargetSessionId,
3282
3373
  cwd: resolvedWorkspaceRoot,
3283
3374
  env: workerLaunchEnv,
@@ -3356,6 +3447,7 @@ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
3356
3447
  });
3357
3448
  const updateInstallService = createUpdateInstallService({
3358
3449
  runningVersion: RUNNING_VERSION,
3450
+ describeNextBackup: () => describeNextBackup({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, dbPath: DB_PATH, config }),
3359
3451
  installMethod: INSTALL_METHOD,
3360
3452
  packageRoot: PACKAGE_ROOT,
3361
3453
  readSetting: readAppSettingValue,
@@ -3673,6 +3765,7 @@ function collectRestartPicture({ action = 'restart' } = {}) {
3673
3765
  restartPictureCollector = createRestartPictureCollector({
3674
3766
  db,
3675
3767
  backgroundWorkView,
3768
+ workerScopes: () => workerScope.inUse(),
3676
3769
  listHeldTasks: () => backgroundTaskStore.listRestored().map(({ key, task }) => ({
3677
3770
  conversationId: resolveConversationRecord({ conversationId: key, sdkSessionId: key })?.id || key,
3678
3771
  description: task?.description || task?.taskType || '',
@@ -4052,19 +4145,28 @@ function parseBoardContext(rawContext) {
4052
4145
 
4053
4146
  function formatRelayBoardRow(row) {
4054
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));
4055
4151
  return {
4056
4152
  id: row.id,
4057
4153
  queueId: row.queue_id,
4058
4154
  conversationId: row.conversation_id,
4059
4155
  messageId: row.message_id,
4060
- boardType: String(row.board_type || '').trim().toLowerCase() || 'generic',
4156
+ boardType,
4061
4157
  mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
4062
4158
  title: String(row.title || '').trim(),
4063
4159
  body: String(row.body || '').trim(),
4064
- actions: parseBoardActions(row.actions_json),
4065
- 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,
4066
4168
  context: parseBoardContext(row.context_json),
4067
- status: String(row.status || 'pending').trim().toLowerCase() || 'pending',
4169
+ status,
4068
4170
  selectedAction: String(row.selected_action || '').trim() || null,
4069
4171
  actedAt: row.acted_at || null,
4070
4172
  createdAt: row.created_at,
@@ -5246,6 +5348,8 @@ function createCompactedConversation(sourceConversationId) {
5246
5348
  // The new conversation stays in the old one's session group, and heads
5247
5349
  // it if the old one did; the archived source stays a member.
5248
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 });
5249
5353
  // A Claude chat keeps its billing (the subscription or the API key, a
5250
5354
  // pending swap's included): the new chat's first worker launches on it,
5251
5355
  // and it is never guessed again from a Claude model id. Other providers
@@ -5547,6 +5651,9 @@ const usageLimitPauseService = createUsageLimitPauseService({
5547
5651
  // workers' reports (services/claude-api-usage-service.mjs); the Claude API
5548
5652
  // card and its cap read it.
5549
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 });
5550
5657
  // This month's spend on the key against the cap, and the Check Usage card
5551
5658
  // (services/plan-usage-claude-api.mjs), over the same local days.
5552
5659
  const claudeApiPlanUsage = createClaudeApiPlanUsage({
@@ -7359,6 +7466,16 @@ const relayAskLinkService = createRelayAskLinkService({
7359
7466
  client: remoteRelayClient,
7360
7467
  logger: console,
7361
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
+ });
7362
7479
  const remoteRelayPairing = createRemoteRelayPairing({
7363
7480
  registry: remoteRelayRegistry,
7364
7481
  client: remoteRelayClient,
@@ -7644,6 +7761,7 @@ const sharedRouteDeps = {
7644
7761
  usageLimitPauseService,
7645
7762
  claudeBillingSwitchService,
7646
7763
  claudeApiUsageService,
7764
+ tokenUsageService,
7647
7765
  claudeApiClaimCheck,
7648
7766
  onClaudeBillingFence: handleClaudeBillingFence,
7649
7767
  claudeApiPlanUsage,
@@ -7716,11 +7834,13 @@ registerRelayAskRoutes(app, {
7716
7834
  db,
7717
7835
  stmts,
7718
7836
  formatQuestionRow,
7837
+ formatRelayBoardRow,
7719
7838
  relayAskService,
7720
7839
  relayAskLinkService,
7721
7840
  remoteRelayInbound,
7722
7841
  remoteRelayRegistry,
7723
7842
  remoteRelayClient,
7843
+ questionNotice: (questionId) => pushDispatchService.questionNotice(questionId),
7724
7844
  });
7725
7845
  registerRelayRestartRoutes(app, {
7726
7846
  auth,
@@ -7755,6 +7875,7 @@ registerAboutRoutes(app, {
7755
7875
  isRelayCliOnline: () => runtimeState.cliOnline === true,
7756
7876
  listWorkers: () => sessionWorkerRegistry.listWorkers(),
7757
7877
  getUpdateState: () => buildUpdateStatePayload(),
7878
+ describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
7758
7879
  }),
7759
7880
  });
7760
7881
  function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
@@ -7856,6 +7977,13 @@ runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
7856
7977
  expirePendingQuestions();
7857
7978
  runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
7858
7979
  if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
7980
+ // Memory in the status log: now, every 10 minutes, and once per crossing of
7981
+ // 80 % of the heap limit (relay-heap-guard.mjs).
7982
+ {
7983
+ const memoryTimers = startRelayMemoryLog({ logger: console });
7984
+ runtimeTimers.memoryLog = memoryTimers.log;
7985
+ runtimeTimers.memoryWatch = memoryTimers.check;
7986
+ }
7859
7987
  const sweptUploads = sweepUnreferencedUploadBlobs();
7860
7988
  if (sweptUploads > 0) {
7861
7989
  console.log(`${runtimeLogPrefix()}Reclaimed ${sweptUploads} unreferenced upload(s)`);
@@ -8099,6 +8227,8 @@ relayEventLinkService.start();
8099
8227
  relayScheduleService.start();
8100
8228
  // Asks on paired relays still open there: look for their answers again.
8101
8229
  relayAskLinkService.start();
8230
+ // Paired relays' Inboxes, every ~20 s.
8231
+ remoteInboxWatchService.start();
8102
8232
  runtimeTimers.pendingWorkerPrime = setInterval(() => {
8103
8233
  void primePendingSessionWorkers('maintenance');
8104
8234
  detachedWorkerWatch.sweep().catch((error) => {
@@ -8421,6 +8551,7 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
8421
8551
  try { relayEventLinkService.stop(); } catch {}
8422
8552
  try { relayScheduleService.stop(); } catch {}
8423
8553
  try { relayAskLinkService.stop(); } catch {}
8554
+ try { remoteInboxWatchService.stop(); } catch {}
8424
8555
  tmuxInspectorSocketService.stop();
8425
8556
  stopWorkspaceFileWatcher();
8426
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,