@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
@@ -17,7 +17,7 @@ import {
17
17
  } from '../services/conversation-history-page.mjs';
18
18
  import { MESSAGE_PIN_LIMIT, buildPinList } from '../services/message-pin-list.mjs';
19
19
  import { buildHiddenMessageList } from '../services/hidden-message-list.mjs';
20
- import { persistConversationPreferences, readMayCreateSessions } from '../services/conversation-preferences-service.mjs';
20
+ import { persistConversationPreferences, readMayCreateSessions, readPlanOnly } from '../services/conversation-preferences-service.mjs';
21
21
  import { parseAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
22
22
  import {
23
23
  claudeBackgroundCommandLimitSettingsFields,
@@ -43,6 +43,7 @@ import { readStoredClaudeContextUsage, resolveAutoCompactWindowStatus } from '..
43
43
  import { buildContextUsageView } from '../services/context-usage-view.mjs';
44
44
  import { cleanupGeneratedImagesForConversation as cleanupGeneratedImagesForConversationDefault } from '../services/generated-image-cleanup-service.mjs';
45
45
  import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
46
+ import { isWorkerOnOtherBuild } from '../services/worker-recycle-service.mjs';
46
47
  import { createClaudeAuthService } from '../services/claude-auth-service.mjs';
47
48
  import { createCliInstallService } from '../services/cli-install-service.mjs';
48
49
  import { createGrokAuthService } from '../services/grok-auth-service.mjs';
@@ -57,6 +58,7 @@ import {
57
58
  DEEPSEEK_REASONING_EFFORTS,
58
59
  DEFAULT_DEEPSEEK_MODEL,
59
60
  DEFAULT_DEEPSEEK_REASONING_EFFORT,
61
+ deepseekModelLabel,
60
62
  deepseekReasoningEffortFor,
61
63
  } from '../../shared/deepseek-models.mjs';
62
64
  import {
@@ -68,7 +70,7 @@ import { redactPreviewTokens } from '../services/preview-registry-service.mjs';
68
70
  import { createSharePathRedactor } from '../services/share-path-redaction.mjs';
69
71
  import { createSubagentTimelineService, TIMELINE_READ_DEFAULT_LIMIT } from '../services/subagent-timeline-service.mjs';
70
72
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
71
- import { createKeyedMutex } from '../services/keyed-mutex-service.mjs';
73
+ import { sessionLaunchMutex } from '../services/session-launch-mutex-service.mjs';
72
74
  import { createFixedWindowRateLimiter } from '../services/rate-limit-service.mjs';
73
75
  import { requestHostOf } from '../services/request-boundary.mjs';
74
76
  import { parseRemoteRelayOriginJson } from '../repositories/remote-relay-repository.mjs';
@@ -103,6 +105,7 @@ import {
103
105
  resolveSessionGroupReference,
104
106
  validateRemoteRelaySessionSettings,
105
107
  } from '../../shared/remote-relay-contract.mjs';
108
+ import { PLAN_ONLY_REFUSAL, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
106
109
  import {
107
110
  buildCloudSourceRecord,
108
111
  buildConversationCloudPayload,
@@ -148,6 +151,7 @@ import {
148
151
  import { parseRestartResumeUpdate } from '../services/restart-resume-service.mjs';
149
152
  import { removeClaudeSessionLeftovers } from '../services/claude-session-leftovers.mjs';
150
153
  import { deepseekStartingBalanceNote } from '../services/plan-usage-deepseek.mjs';
154
+ import { appendTokenSections } from '../services/plan-usage-tokens.mjs';
151
155
  import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from '../migrations/0024-deepseek-usage-totals.mjs';
152
156
  import { claudeProjectDirSlug } from '../services/claude-session-root-service.mjs';
153
157
 
@@ -1449,11 +1453,27 @@ export function buildSessionWorkerStatusPayload({
1449
1453
  // What the last read of the process list did ({ at, durationMs, processes,
1450
1454
  // error }); a slow box shows here before it shows as 502s.
1451
1455
  processList = null,
1456
+ // The relay's own build id (runtimeState.buildId): each worker's is shown
1457
+ // next to it, so a worker still on the code before a deploy stands out.
1458
+ relayBuildId = null,
1459
+ // The worker recycle's status() (stale count, recycles in flight and done,
1460
+ // who waits on what), or null while the recycle is not wired.
1461
+ recycleStatus = null,
1452
1462
  } = {}) {
1453
1463
  const snapshot = supervisorSnapshot && typeof supervisorSnapshot === 'object'
1454
1464
  ? supervisorSnapshot
1455
1465
  : {};
1456
- const workers = Array.isArray(snapshot.workers) ? snapshot.workers : [];
1466
+ const relayBuild = typeof relayBuildId === 'string' && relayBuildId.trim() ? relayBuildId.trim() : null;
1467
+ const workers = (Array.isArray(snapshot.workers) ? snapshot.workers : []).map((worker) => (
1468
+ worker && typeof worker === 'object'
1469
+ ? {
1470
+ ...worker,
1471
+ buildId: typeof worker.buildId === 'string' && worker.buildId ? worker.buildId : null,
1472
+ startedAt: worker.startedAt || null,
1473
+ stale: isWorkerOnOtherBuild(worker, relayBuild),
1474
+ }
1475
+ : worker
1476
+ ));
1457
1477
  const onlineWorkerStatuses = new Set(SESSION_WORKER_ACTIVE_STATUSES);
1458
1478
  const onlineCount = workers.reduce((count, worker) => {
1459
1479
  const status = normalizeWorkerStatusText(worker?.status, 'new');
@@ -1558,6 +1578,8 @@ export function buildSessionWorkerStatusPayload({
1558
1578
  onlineUnassignedProcessCount,
1559
1579
  counts: snapshot.counts && typeof snapshot.counts === 'object' ? snapshot.counts : {},
1560
1580
  workers,
1581
+ buildId: relayBuild,
1582
+ recycle: recycleStatus && typeof recycleStatus === 'object' ? recycleStatus : null,
1561
1583
  pendingStarts: toSafeNonNegativeInt(snapshot.pendingStarts, 0),
1562
1584
  lifecycle: Array.isArray(snapshot.lifecycle) ? snapshot.lifecycle : [],
1563
1585
  integrity,
@@ -2243,6 +2265,9 @@ export function registerSessionsRoutes(app, deps) {
2243
2265
  // The once-per-episode notes for stopped self-started turns on the key
2244
2266
  // (plan-usage-claude-api.mjs): a saved setting starts every chat afresh.
2245
2267
  claudeApiStopLedger = null,
2268
+ // Tokens per turn and per day (services/token-usage-service.mjs): a
2269
+ // deleted chat drops its turn rows. Absent in older wiring and most tests.
2270
+ tokenUsageService = null,
2246
2271
  reconcileUnstartedConversationProviders = async () => ({
2247
2272
  updatedUnstartedConversations: 0,
2248
2273
  skippedStartedConversations: 0,
@@ -2580,6 +2605,8 @@ export function registerSessionsRoutes(app, deps) {
2580
2605
  if (findDeepSeekUsageCheckpointsTable.get(DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
2581
2606
  db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ?`).run(conversationId);
2582
2607
  }
2608
+ // Its turns' tokens go with it; the day sums behind Check Usage stay.
2609
+ tokenUsageService?.forgetConversation?.(conversationId);
2583
2610
  // The share access log goes with it; the share rows follow the foreign key.
2584
2611
  shares().forgetConversation(conversationId);
2585
2612
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
@@ -2600,9 +2627,10 @@ export function registerSessionsRoutes(app, deps) {
2600
2627
  bucketTtlMs: 60_000,
2601
2628
  maxBuckets: 4_096,
2602
2629
  });
2603
- // Serializes everything that reaches the session-worker launch path, keyed by
2604
- // sdkSessionId, plus a coalescer so a duplicated request replays one result.
2605
- const sessionLaunchMutex = createKeyedMutex();
2630
+ // Everything that reaches the session-worker launch path is serialized by
2631
+ // the relay-wide sessionLaunchMutex (services/session-launch-mutex-service.mjs),
2632
+ // keyed by sdkSessionId, which the worker recycle takes too; plus a
2633
+ // coalescer so a duplicated request replays one result.
2606
2634
  const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
2607
2635
 
2608
2636
  function getDeepSeekProviderSettings() {
@@ -3398,6 +3426,7 @@ export function registerSessionsRoutes(app, deps) {
3398
3426
  origin: parseRemoteRelayOriginJson(r.origin_json),
3399
3427
  // The owner let this agent-started session's agents start sessions.
3400
3428
  mayCreateSessions: readMayCreateSessions(r),
3429
+ planOnly: readPlanOnly(r),
3401
3430
  // Set on Claude Cloud conversations: repository, branch, session link.
3402
3431
  cloud: buildConversationCloudPayload({
3403
3432
  cloudSourceJson: r.cloud_source_json,
@@ -4158,6 +4187,7 @@ export function registerSessionsRoutes(app, deps) {
4158
4187
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
4159
4188
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4160
4189
  mayCreateSessions: readMayCreateSessions(conv),
4190
+ planOnly: readPlanOnly(conv),
4161
4191
  cloud: conversationCloudPayload(conv, runtimeSession),
4162
4192
  group: conversationGroupPayload(conversationId),
4163
4193
  groupPosition: conversationGroupPositionOf(conv),
@@ -4864,6 +4894,7 @@ export function registerSessionsRoutes(app, deps) {
4864
4894
  // Set on conversations another relay's agent created ("via <relay>").
4865
4895
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4866
4896
  mayCreateSessions: readMayCreateSessions(conv),
4897
+ planOnly: readPlanOnly(conv),
4867
4898
  // Set on Claude Cloud conversations: repository, branch, session link.
4868
4899
  cloud: conversationCloudPayload(conv, runtimeSession),
4869
4900
  // The session group it is in, with the other members; null when none.
@@ -5086,6 +5117,7 @@ export function registerSessionsRoutes(app, deps) {
5086
5117
  ? { thinkingDisplay: parseThinkingDisplay(body.thinkingDisplay) }
5087
5118
  : {}),
5088
5119
  ...(mentions('mayCreateSessions') ? { mayCreateSessions: body.mayCreateSessions === true } : {}),
5120
+ ...(mentions('planOnly') ? { planOnly: body.planOnly === true } : {}),
5089
5121
  updatedAt: now,
5090
5122
  createIfMissing: !existing,
5091
5123
  createTitle: 'Session',
@@ -5099,6 +5131,7 @@ export function registerSessionsRoutes(app, deps) {
5099
5131
  thinkingEnabled: persisted.thinkingEnabled ?? null,
5100
5132
  thinkingDisplay: persisted.thinkingDisplay ?? null,
5101
5133
  mayCreateSessions: persisted.mayCreateSessions === true,
5134
+ planOnly: persisted.planOnly === true,
5102
5135
  updatedAt: persisted.updatedAt,
5103
5136
  senderClientId,
5104
5137
  };
@@ -5128,6 +5161,26 @@ export function registerSessionsRoutes(app, deps) {
5128
5161
  return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5129
5162
  }
5130
5163
  }
5164
+ // Plan-only: the owner's toggle here. Agents set and clear it through
5165
+ // update_session only (its dry run and Allow card), never through this
5166
+ // route, and they cannot change a plan-only session's mode here either.
5167
+ const remoteRequest = readRemoteRelayRequest(req, remoteRelayInbound).remote;
5168
+ if (Object.prototype.hasOwnProperty.call(req.body || {}, 'planOnly')) {
5169
+ if (remoteRequest) {
5170
+ return res.status(403).json({ error: 'Agents set plan-only through relay_tool update_session, not through this route.' });
5171
+ }
5172
+ if (typeof req.body.planOnly !== 'boolean') {
5173
+ return res.status(400).json({ error: 'planOnly must be true or false' });
5174
+ }
5175
+ if (!stmts.conversationsHavePlanOnly) {
5176
+ return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5177
+ }
5178
+ }
5179
+ if (remoteRequest && readPlanOnly(existing)
5180
+ && Object.prototype.hasOwnProperty.call(req.body || {}, 'preferredRelayMode')
5181
+ && planOnlyRefusesMode(req.body.preferredRelayMode)) {
5182
+ return res.status(409).json(PLAN_ONLY_REFUSAL);
5183
+ }
5131
5184
 
5132
5185
  const persisted = applyConversationPreferences({ conversationId, existing, body: req.body || {}, senderClientId });
5133
5186
  return res.json({ ok: true, ...persisted });
@@ -5168,6 +5221,7 @@ export function registerSessionsRoutes(app, deps) {
5168
5221
  thinking_enabled: parseThinkingEnabled(conv.thinking_enabled),
5169
5222
  thinking_display: parseThinkingDisplay(conv.thinking_display),
5170
5223
  cwd: String(workspace?.currentWorkspaceRootPath || workspace?.configuredWorkspaceRootPath || conv.configured_workspace_root_path || '').trim() || null,
5224
+ planOnly: readPlanOnly(conv),
5171
5225
  };
5172
5226
  }
5173
5227
 
@@ -5201,7 +5255,7 @@ export function registerSessionsRoutes(app, deps) {
5201
5255
  * body }`. The plan holds what changes (`changed`, in the order of the
5202
5256
  * contract's settings) and how.
5203
5257
  */
5204
- function planSessionSettings(conversation, raw) {
5258
+ function planSessionSettings(conversation, raw, { remote = false } = {}) {
5205
5259
  const conversationId = String(conversation.id);
5206
5260
  const fail = (status, error, extra = {}) => ({ ok: false, status, body: { ok: false, error, ...extra } });
5207
5261
  const settingsInput = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
@@ -5236,6 +5290,30 @@ export function registerSessionsRoutes(app, deps) {
5236
5290
  change('mode');
5237
5291
  }
5238
5292
  }
5293
+ // Plan-only, read as it is after this call: one that clears the flag may
5294
+ // name another mode too, one that keeps or sets it may not. Checked before
5295
+ // anything else is planned, so a dry run refuses before any Allow card.
5296
+ if (wanted.planOnly !== undefined && !stmts.conversationsHavePlanOnly) {
5297
+ return fail(503, 'planOnly is unavailable until the relay restarts (its storage is missing). Nothing was changed.', { code: SESSION_SETTINGS_INVALID });
5298
+ }
5299
+ // planOnly:false counts only in the call that switches to another mode:
5300
+ // alone (or with plan) it is passed over, so a caller that fills every
5301
+ // property with false never drops the lock by accident.
5302
+ if (wanted.planOnly === false && !(wanted.mode !== undefined && wanted.mode !== 'plan')) {
5303
+ delete wanted.planOnly;
5304
+ if (current.planOnly === true) plan.notes.push('planOnly unchanged: clear it in the same call as the new mode.');
5305
+ }
5306
+ const planOnlyAfter = wanted.planOnly !== undefined ? wanted.planOnly === true : current.planOnly === true;
5307
+ if (remote && planOnlyAfter && wanted.mode !== undefined && planOnlyRefusesMode(wanted.mode)) {
5308
+ return fail(409, `${PLAN_ONLY_REFUSAL.error} Nothing was changed.`, { code: PLAN_ONLY_REFUSAL.code });
5309
+ }
5310
+ if (wanted.planOnly !== undefined && wanted.planOnly !== current.planOnly) {
5311
+ if (wanted.planOnly === true && (wanted.mode !== undefined ? wanted.mode : current.mode) !== 'plan') {
5312
+ return fail(400, `planOnly keeps a session in plan mode, and this one is in ${current.mode} mode: add mode: "plan" in the same call. Nothing was changed.`, { code: SESSION_SETTINGS_INVALID });
5313
+ }
5314
+ plan.preferences.planOnly = wanted.planOnly;
5315
+ change('planOnly');
5316
+ }
5239
5317
  if (wanted.auto_compact_window !== undefined) {
5240
5318
  const window = wanted.auto_compact_window === 'auto' ? null : parseAutoCompactWindow(wanted.auto_compact_window);
5241
5319
  if (window !== parseAutoCompactWindow(conversation.auto_compact_window)) {
@@ -5422,7 +5500,7 @@ export function registerSessionsRoutes(app, deps) {
5422
5500
  }
5423
5501
  const conversationId = String(conversation.id);
5424
5502
  const body = req.body && typeof req.body === 'object' ? req.body : {};
5425
- const planned = planSessionSettings(conversation, body.settings);
5503
+ const planned = planSessionSettings(conversation, body.settings, { remote: remoteRequest.remote === true });
5426
5504
  if (!planned.ok) return res.status(planned.status).json(planned.body);
5427
5505
  const { plan } = planned;
5428
5506
  const createdBy = parseRemoteRelayOriginJson(conversation.origin_json);
@@ -6041,7 +6119,7 @@ export function registerSessionsRoutes(app, deps) {
6041
6119
  // Kill session stops the session's worker, and the delete counts no
6042
6120
  // tasks for a conversation whose worker is gone.
6043
6121
  message: activity.unstoppableTaskCount > 0
6044
- ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then delete it.'
6122
+ ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ︙ menu, then delete it.'
6045
6123
  : 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
6046
6124
  },
6047
6125
  };
@@ -6074,6 +6152,7 @@ export function registerSessionsRoutes(app, deps) {
6074
6152
  stopOverrides: sessionWorkerStopOverrides,
6075
6153
  });
6076
6154
  if (!stopped.ok) {
6155
+ console.warn(`[delete] Not deleted ${id}: its worker did not stop (${stopped.error || 'unknown'}; remaining pids ${(stopped.remainingPids || []).join(',') || 'none'}).`);
6077
6156
  return {
6078
6157
  statusCode: 409,
6079
6158
  body: {
@@ -6199,7 +6278,7 @@ export function registerSessionsRoutes(app, deps) {
6199
6278
  error: 'conversation-active',
6200
6279
  reason: 'background-tasks',
6201
6280
  message: activity.unstoppableTaskCount > 0
6202
- ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then archive it.'
6281
+ ? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ︙ menu, then archive it.'
6203
6282
  : 'Background tasks are still running in this conversation. Stop them in the task panel, then archive it.',
6204
6283
  },
6205
6284
  };
@@ -6220,6 +6299,7 @@ export function registerSessionsRoutes(app, deps) {
6220
6299
  stopOverrides: sessionWorkerStopOverrides,
6221
6300
  });
6222
6301
  if (!stopped.ok) {
6302
+ console.warn(`[archive] Not archived ${id}: its worker did not stop (${stopped.error || 'unknown'}; remaining pids ${(stopped.remainingPids || []).join(',') || 'none'}).`);
6223
6303
  return {
6224
6304
  statusCode: 409,
6225
6305
  body: {
@@ -6829,10 +6909,27 @@ export function registerSessionsRoutes(app, deps) {
6829
6909
  // new chat would be stored as the default one and reset the composer. An
6830
6910
  // omitted or unknown mode still falls back to the default rather than 400,
6831
6911
  // because a new chat is worth starting either way.
6832
- const bootstrapRelayMode = normalizeRelayModePreference(req.body?.relayMode ?? req.body?.preferredRelayMode, {
6833
- supportedRelayModes: SUPPORTED_RELAY_MODES,
6834
- fallbackMode: DEFAULT_RELAY_MODE,
6835
- });
6912
+ // Plan-only: a session that starts locked in plan mode. With no
6913
+ // mode named it starts in plan; a mode that is not plan is refused.
6914
+ const bootstrapPlanOnly = Object.prototype.hasOwnProperty.call(req.body || {}, 'planOnly') ? req.body.planOnly : undefined;
6915
+ if (bootstrapPlanOnly !== undefined && typeof bootstrapPlanOnly !== 'boolean') {
6916
+ return res.status(400).json({ ok: false, error: 'planOnly must be true or false', code: 'PLAN_ONLY_INVALID' });
6917
+ }
6918
+ const namedBootstrapMode = req.body?.relayMode ?? req.body?.preferredRelayMode;
6919
+ if (bootstrapPlanOnly === true) {
6920
+ if (!stmts.conversationsHavePlanOnly) {
6921
+ return res.status(503).json({ ok: false, error: 'planOnly is unavailable until the relay restarts (its storage is missing). No conversation was created.', code: 'PLAN_ONLY_UNAVAILABLE' });
6922
+ }
6923
+ if (String(namedBootstrapMode ?? '').trim() && String(namedBootstrapMode).trim().toLowerCase() !== 'plan') {
6924
+ return res.status(400).json({ ok: false, error: `planOnly keeps a session in plan mode; it cannot start in ${String(namedBootstrapMode).trim()} mode. No conversation was created.`, code: 'PLAN_ONLY_MODE' });
6925
+ }
6926
+ }
6927
+ const bootstrapRelayMode = bootstrapPlanOnly === true
6928
+ ? 'plan'
6929
+ : normalizeRelayModePreference(namedBootstrapMode, {
6930
+ supportedRelayModes: SUPPORTED_RELAY_MODES,
6931
+ fallbackMode: DEFAULT_RELAY_MODE,
6932
+ });
6836
6933
  // The New Chat modal's reasoning choice is validated with the same rules the
6837
6934
  // first send would apply, so the composer can restore it from the server
6838
6935
  // instead of guessing from localStorage.
@@ -6910,6 +7007,7 @@ export function registerSessionsRoutes(app, deps) {
6910
7007
  preferredRelayMode: bootstrapRelayMode,
6911
7008
  preferredModel: selectedModel,
6912
7009
  preferredReasoningEffort: bootstrapReasoning.effort,
7010
+ ...(bootstrapPlanOnly === true ? { planOnly: true } : {}),
6913
7011
  updatedAt: now,
6914
7012
  });
6915
7013
  return { boundRuntimeSession, preferences };
@@ -7028,6 +7126,8 @@ export function registerSessionsRoutes(app, deps) {
7028
7126
  preferredRelayMode,
7029
7127
  preferredModel,
7030
7128
  preferredReasoningEffort,
7129
+ // Echoed so the calling relay can tell this relay knows the flag.
7130
+ planOnly: preferences?.planOnly === true,
7031
7131
  configuredWorkspaceRootPath: workspaceRootState?.configuredWorkspaceRootPath || null,
7032
7132
  configuredWorkspaceRootName: workspaceRootState?.configuredWorkspaceRootName || null,
7033
7133
  currentWorkspaceRootPath: workspaceRootState?.currentWorkspaceRootPath || null,
@@ -7049,6 +7149,18 @@ export function registerSessionsRoutes(app, deps) {
7049
7149
  }
7050
7150
  });
7051
7151
 
7152
+ // The worker recycle's status, read at request time: the recycle is set on
7153
+ // runtimeState after the routes are registered, and a status that throws
7154
+ // must not take /api/status down with it.
7155
+ function readWorkerRecycleStatus() {
7156
+ if (typeof runtimeState.workerRecycle !== 'function') return null;
7157
+ try {
7158
+ return runtimeState.workerRecycle() || null;
7159
+ } catch {
7160
+ return null;
7161
+ }
7162
+ }
7163
+
7052
7164
  // GET /api/status — overall status
7053
7165
  app.get('/api/status', auth, (req, res) => {
7054
7166
  ensureSessionId(req, res);
@@ -7066,6 +7178,8 @@ export function registerSessionsRoutes(app, deps) {
7066
7178
  supervisorSnapshot: sessionWorkerSupervisor?.snapshot?.({ pendingQuestionSessionIds }) || null,
7067
7179
  queueRows: listSessionWorkerQueueRows.all(...SESSION_WORKER_STATUS_QUEUE_STATES),
7068
7180
  processList: sessionWorkerProcessInspector?.getLastRead?.() || null,
7181
+ relayBuildId: runtimeState.buildId ?? null,
7182
+ recycleStatus: readWorkerRecycleStatus(),
7069
7183
  });
7070
7184
  const activeRuntimeSessionCount = featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
7071
7185
  ? Number(sessionWorkerStatus?.onlineBoundProcessCount || 0)
@@ -9141,6 +9255,22 @@ export function registerSessionsRoutes(app, deps) {
9141
9255
  deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9142
9256
  claudeApiCard,
9143
9257
  });
9258
+ // Every tab's Tokens sections, from the token booking.
9259
+ if (tokenUsageService) {
9260
+ appendTokenSections(report.providers, (options) => tokenUsageService.readPeriods(options), {
9261
+ // A tab names its models as its other rows do: the Claude API tab as
9262
+ // its spend rows (the Console's names), DeepSeek with its own labels,
9263
+ // the Claude tab with Claude's; the raw id where there is none.
9264
+ modelLabelFor: (provider) => {
9265
+ if (provider === 'claude-api' && typeof claudeApiPlanUsage?.modelLabel === 'function') {
9266
+ return (model) => claudeApiPlanUsage.modelLabel(model);
9267
+ }
9268
+ if (provider === 'deepseek') return (model) => deepseekModelLabel(model);
9269
+ if (provider === 'claude') return (model) => claudeAttributionModelLabel(model);
9270
+ return null;
9271
+ },
9272
+ });
9273
+ }
9144
9274
 
9145
9275
  // Legacy top-level fields stay alongside `providers` so an older cached
9146
9276
  // client shell keeps working through a PWA update.