@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
@@ -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,
@@ -57,6 +57,7 @@ import {
57
57
  DEEPSEEK_REASONING_EFFORTS,
58
58
  DEFAULT_DEEPSEEK_MODEL,
59
59
  DEFAULT_DEEPSEEK_REASONING_EFFORT,
60
+ deepseekModelLabel,
60
61
  deepseekReasoningEffortFor,
61
62
  } from '../../shared/deepseek-models.mjs';
62
63
  import {
@@ -103,6 +104,7 @@ import {
103
104
  resolveSessionGroupReference,
104
105
  validateRemoteRelaySessionSettings,
105
106
  } from '../../shared/remote-relay-contract.mjs';
107
+ import { PLAN_ONLY_REFUSAL, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
106
108
  import {
107
109
  buildCloudSourceRecord,
108
110
  buildConversationCloudPayload,
@@ -148,6 +150,7 @@ import {
148
150
  import { parseRestartResumeUpdate } from '../services/restart-resume-service.mjs';
149
151
  import { removeClaudeSessionLeftovers } from '../services/claude-session-leftovers.mjs';
150
152
  import { deepseekStartingBalanceNote } from '../services/plan-usage-deepseek.mjs';
153
+ import { appendTokenSections } from '../services/plan-usage-tokens.mjs';
151
154
  import { DEEPSEEK_USAGE_CHECKPOINTS_TABLE } from '../migrations/0024-deepseek-usage-totals.mjs';
152
155
  import { claudeProjectDirSlug } from '../services/claude-session-root-service.mjs';
153
156
 
@@ -501,15 +504,34 @@ export function parseGrokSettingsUpdateRequest(body = {}) {
501
504
  };
502
505
  }
503
506
 
504
- export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
505
- const configured = claudeSettings?.enabled === true;
506
- const model = String(claudeSettings?.model || '').trim();
507
- if (!configured || !model) return { ...modelState };
508
- const baseModels = new Set(
507
+ /**
508
+ * The lower-cased ids Copilot itself lists in `modelState`: those an earlier
509
+ * provider layer did not add. A layer tags every id it adds, so an id with
510
+ * tags but none of them Copilot's is another provider's ("claude-opus-5-5"
511
+ * from the Claude layer), not one Copilot serves too.
512
+ */
513
+ function copilotListedModelIds(modelState = {}) {
514
+ const providersByModel = modelState?.providersByModel && typeof modelState.providersByModel === 'object'
515
+ ? modelState.providersByModel
516
+ : {};
517
+ return new Set(
509
518
  (Array.isArray(modelState?.models) ? modelState.models : [])
519
+ .filter((value) => {
520
+ const id = String(value || '').trim();
521
+ const providers = providersByModel[id] || providersByModel[id.toLowerCase()];
522
+ return !Array.isArray(providers) || providers.length === 0
523
+ || providers.some((provider) => String(provider || '').trim().toLowerCase() === 'github-copilot');
524
+ })
510
525
  .map((value) => String(value || '').trim().toLowerCase())
511
526
  .filter((value) => value && value.toLowerCase() !== 'auto'),
512
527
  );
528
+ }
529
+
530
+ export function buildModelCatalogWithClaudeProvider(modelState = {}, claudeSettings = {}) {
531
+ const configured = claudeSettings?.enabled === true;
532
+ const model = String(claudeSettings?.model || '').trim();
533
+ if (!configured || !model) return { ...modelState };
534
+ const baseModels = copilotListedModelIds(modelState);
513
535
  const claudeModels = Array.isArray(claudeSettings?.models)
514
536
  ? claudeSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
515
537
  : [];
@@ -605,11 +627,7 @@ export function buildModelCatalogWithOpenAIProvider(modelState = {}, openAISetti
605
627
  const configured = openAISettings?.enabled === true;
606
628
  const model = String(openAISettings?.model || '').trim();
607
629
  if (!configured || !model) return { ...modelState };
608
- const baseModels = new Set(
609
- (Array.isArray(modelState?.models) ? modelState.models : [])
610
- .map((value) => String(value || '').trim().toLowerCase())
611
- .filter((value) => value && value.toLowerCase() !== 'auto'),
612
- );
630
+ const baseModels = copilotListedModelIds(modelState);
613
631
  const openAIModels = Array.isArray(openAISettings?.models)
614
632
  ? openAISettings.models.map((value) => String(value || '').trim()).filter(Boolean)
615
633
  : [];
@@ -659,11 +677,7 @@ export function buildModelCatalogWithCursorProvider(modelState = {}, cursorSetti
659
677
  const configured = cursorSettings?.enabled === true;
660
678
  const model = String(cursorSettings?.model || '').trim();
661
679
  if (!configured || !model) return { ...modelState };
662
- const baseModels = new Set(
663
- (Array.isArray(modelState?.models) ? modelState.models : [])
664
- .map((value) => String(value || '').trim().toLowerCase())
665
- .filter((value) => value && value.toLowerCase() !== 'auto'),
666
- );
680
+ const baseModels = copilotListedModelIds(modelState);
667
681
  const cursorModelList = Array.isArray(cursorSettings?.models)
668
682
  ? cursorSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
669
683
  : [];
@@ -748,11 +762,7 @@ export function buildModelCatalogWithGrokProvider(modelState = {}, grokSettings
748
762
  const configured = grokSettings?.enabled === true;
749
763
  const model = String(grokSettings?.model || '').trim();
750
764
  if (!configured || !model) return { ...modelState };
751
- const baseModels = new Set(
752
- (Array.isArray(modelState?.models) ? modelState.models : [])
753
- .map((value) => String(value || '').trim().toLowerCase())
754
- .filter((value) => value && value.toLowerCase() !== 'auto'),
755
- );
765
+ const baseModels = copilotListedModelIds(modelState);
756
766
  const grokModelList = Array.isArray(grokSettings?.models)
757
767
  ? grokSettings.models.map((value) => String(value || '').trim()).filter(Boolean)
758
768
  : [];
@@ -2236,6 +2246,9 @@ export function registerSessionsRoutes(app, deps) {
2236
2246
  // The once-per-episode notes for stopped self-started turns on the key
2237
2247
  // (plan-usage-claude-api.mjs): a saved setting starts every chat afresh.
2238
2248
  claudeApiStopLedger = null,
2249
+ // Tokens per turn and per day (services/token-usage-service.mjs): a
2250
+ // deleted chat drops its turn rows. Absent in older wiring and most tests.
2251
+ tokenUsageService = null,
2239
2252
  reconcileUnstartedConversationProviders = async () => ({
2240
2253
  updatedUnstartedConversations: 0,
2241
2254
  skippedStartedConversations: 0,
@@ -2573,6 +2586,8 @@ export function registerSessionsRoutes(app, deps) {
2573
2586
  if (findDeepSeekUsageCheckpointsTable.get(DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
2574
2587
  db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ?`).run(conversationId);
2575
2588
  }
2589
+ // Its turns' tokens go with it; the day sums behind Check Usage stay.
2590
+ tokenUsageService?.forgetConversation?.(conversationId);
2576
2591
  // The share access log goes with it; the share rows follow the foreign key.
2577
2592
  shares().forgetConversation(conversationId);
2578
2593
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
@@ -3391,6 +3406,7 @@ export function registerSessionsRoutes(app, deps) {
3391
3406
  origin: parseRemoteRelayOriginJson(r.origin_json),
3392
3407
  // The owner let this agent-started session's agents start sessions.
3393
3408
  mayCreateSessions: readMayCreateSessions(r),
3409
+ planOnly: readPlanOnly(r),
3394
3410
  // Set on Claude Cloud conversations: repository, branch, session link.
3395
3411
  cloud: buildConversationCloudPayload({
3396
3412
  cloudSourceJson: r.cloud_source_json,
@@ -4151,6 +4167,7 @@ export function registerSessionsRoutes(app, deps) {
4151
4167
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
4152
4168
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4153
4169
  mayCreateSessions: readMayCreateSessions(conv),
4170
+ planOnly: readPlanOnly(conv),
4154
4171
  cloud: conversationCloudPayload(conv, runtimeSession),
4155
4172
  group: conversationGroupPayload(conversationId),
4156
4173
  groupPosition: conversationGroupPositionOf(conv),
@@ -4857,6 +4874,7 @@ export function registerSessionsRoutes(app, deps) {
4857
4874
  // Set on conversations another relay's agent created ("via <relay>").
4858
4875
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4859
4876
  mayCreateSessions: readMayCreateSessions(conv),
4877
+ planOnly: readPlanOnly(conv),
4860
4878
  // Set on Claude Cloud conversations: repository, branch, session link.
4861
4879
  cloud: conversationCloudPayload(conv, runtimeSession),
4862
4880
  // The session group it is in, with the other members; null when none.
@@ -5079,6 +5097,7 @@ export function registerSessionsRoutes(app, deps) {
5079
5097
  ? { thinkingDisplay: parseThinkingDisplay(body.thinkingDisplay) }
5080
5098
  : {}),
5081
5099
  ...(mentions('mayCreateSessions') ? { mayCreateSessions: body.mayCreateSessions === true } : {}),
5100
+ ...(mentions('planOnly') ? { planOnly: body.planOnly === true } : {}),
5082
5101
  updatedAt: now,
5083
5102
  createIfMissing: !existing,
5084
5103
  createTitle: 'Session',
@@ -5092,6 +5111,7 @@ export function registerSessionsRoutes(app, deps) {
5092
5111
  thinkingEnabled: persisted.thinkingEnabled ?? null,
5093
5112
  thinkingDisplay: persisted.thinkingDisplay ?? null,
5094
5113
  mayCreateSessions: persisted.mayCreateSessions === true,
5114
+ planOnly: persisted.planOnly === true,
5095
5115
  updatedAt: persisted.updatedAt,
5096
5116
  senderClientId,
5097
5117
  };
@@ -5121,6 +5141,26 @@ export function registerSessionsRoutes(app, deps) {
5121
5141
  return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5122
5142
  }
5123
5143
  }
5144
+ // Plan-only: the owner's toggle here. Agents set and clear it through
5145
+ // update_session only (its dry run and Allow card), never through this
5146
+ // route, and they cannot change a plan-only session's mode here either.
5147
+ const remoteRequest = readRemoteRelayRequest(req, remoteRelayInbound).remote;
5148
+ if (Object.prototype.hasOwnProperty.call(req.body || {}, 'planOnly')) {
5149
+ if (remoteRequest) {
5150
+ return res.status(403).json({ error: 'Agents set plan-only through relay_tool update_session, not through this route.' });
5151
+ }
5152
+ if (typeof req.body.planOnly !== 'boolean') {
5153
+ return res.status(400).json({ error: 'planOnly must be true or false' });
5154
+ }
5155
+ if (!stmts.conversationsHavePlanOnly) {
5156
+ return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5157
+ }
5158
+ }
5159
+ if (remoteRequest && readPlanOnly(existing)
5160
+ && Object.prototype.hasOwnProperty.call(req.body || {}, 'preferredRelayMode')
5161
+ && planOnlyRefusesMode(req.body.preferredRelayMode)) {
5162
+ return res.status(409).json(PLAN_ONLY_REFUSAL);
5163
+ }
5124
5164
 
5125
5165
  const persisted = applyConversationPreferences({ conversationId, existing, body: req.body || {}, senderClientId });
5126
5166
  return res.json({ ok: true, ...persisted });
@@ -5161,6 +5201,7 @@ export function registerSessionsRoutes(app, deps) {
5161
5201
  thinking_enabled: parseThinkingEnabled(conv.thinking_enabled),
5162
5202
  thinking_display: parseThinkingDisplay(conv.thinking_display),
5163
5203
  cwd: String(workspace?.currentWorkspaceRootPath || workspace?.configuredWorkspaceRootPath || conv.configured_workspace_root_path || '').trim() || null,
5204
+ planOnly: readPlanOnly(conv),
5164
5205
  };
5165
5206
  }
5166
5207
 
@@ -5194,7 +5235,7 @@ export function registerSessionsRoutes(app, deps) {
5194
5235
  * body }`. The plan holds what changes (`changed`, in the order of the
5195
5236
  * contract's settings) and how.
5196
5237
  */
5197
- function planSessionSettings(conversation, raw) {
5238
+ function planSessionSettings(conversation, raw, { remote = false } = {}) {
5198
5239
  const conversationId = String(conversation.id);
5199
5240
  const fail = (status, error, extra = {}) => ({ ok: false, status, body: { ok: false, error, ...extra } });
5200
5241
  const settingsInput = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
@@ -5229,6 +5270,30 @@ export function registerSessionsRoutes(app, deps) {
5229
5270
  change('mode');
5230
5271
  }
5231
5272
  }
5273
+ // Plan-only, read as it is after this call: one that clears the flag may
5274
+ // name another mode too, one that keeps or sets it may not. Checked before
5275
+ // anything else is planned, so a dry run refuses before any Allow card.
5276
+ if (wanted.planOnly !== undefined && !stmts.conversationsHavePlanOnly) {
5277
+ return fail(503, 'planOnly is unavailable until the relay restarts (its storage is missing). Nothing was changed.', { code: SESSION_SETTINGS_INVALID });
5278
+ }
5279
+ // planOnly:false counts only in the call that switches to another mode:
5280
+ // alone (or with plan) it is passed over, so a caller that fills every
5281
+ // property with false never drops the lock by accident.
5282
+ if (wanted.planOnly === false && !(wanted.mode !== undefined && wanted.mode !== 'plan')) {
5283
+ delete wanted.planOnly;
5284
+ if (current.planOnly === true) plan.notes.push('planOnly unchanged: clear it in the same call as the new mode.');
5285
+ }
5286
+ const planOnlyAfter = wanted.planOnly !== undefined ? wanted.planOnly === true : current.planOnly === true;
5287
+ if (remote && planOnlyAfter && wanted.mode !== undefined && planOnlyRefusesMode(wanted.mode)) {
5288
+ return fail(409, `${PLAN_ONLY_REFUSAL.error} Nothing was changed.`, { code: PLAN_ONLY_REFUSAL.code });
5289
+ }
5290
+ if (wanted.planOnly !== undefined && wanted.planOnly !== current.planOnly) {
5291
+ if (wanted.planOnly === true && (wanted.mode !== undefined ? wanted.mode : current.mode) !== 'plan') {
5292
+ 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 });
5293
+ }
5294
+ plan.preferences.planOnly = wanted.planOnly;
5295
+ change('planOnly');
5296
+ }
5232
5297
  if (wanted.auto_compact_window !== undefined) {
5233
5298
  const window = wanted.auto_compact_window === 'auto' ? null : parseAutoCompactWindow(wanted.auto_compact_window);
5234
5299
  if (window !== parseAutoCompactWindow(conversation.auto_compact_window)) {
@@ -5415,7 +5480,7 @@ export function registerSessionsRoutes(app, deps) {
5415
5480
  }
5416
5481
  const conversationId = String(conversation.id);
5417
5482
  const body = req.body && typeof req.body === 'object' ? req.body : {};
5418
- const planned = planSessionSettings(conversation, body.settings);
5483
+ const planned = planSessionSettings(conversation, body.settings, { remote: remoteRequest.remote === true });
5419
5484
  if (!planned.ok) return res.status(planned.status).json(planned.body);
5420
5485
  const { plan } = planned;
5421
5486
  const createdBy = parseRemoteRelayOriginJson(conversation.origin_json);
@@ -6067,6 +6132,7 @@ export function registerSessionsRoutes(app, deps) {
6067
6132
  stopOverrides: sessionWorkerStopOverrides,
6068
6133
  });
6069
6134
  if (!stopped.ok) {
6135
+ console.warn(`[delete] Not deleted ${id}: its worker did not stop (${stopped.error || 'unknown'}; remaining pids ${(stopped.remainingPids || []).join(',') || 'none'}).`);
6070
6136
  return {
6071
6137
  statusCode: 409,
6072
6138
  body: {
@@ -6213,6 +6279,7 @@ export function registerSessionsRoutes(app, deps) {
6213
6279
  stopOverrides: sessionWorkerStopOverrides,
6214
6280
  });
6215
6281
  if (!stopped.ok) {
6282
+ console.warn(`[archive] Not archived ${id}: its worker did not stop (${stopped.error || 'unknown'}; remaining pids ${(stopped.remainingPids || []).join(',') || 'none'}).`);
6216
6283
  return {
6217
6284
  statusCode: 409,
6218
6285
  body: {
@@ -6600,6 +6667,7 @@ export function registerSessionsRoutes(app, deps) {
6600
6667
  // lookup below knows exact base ids only and would fall back too.
6601
6668
  let copilotBoundModel = '';
6602
6669
  let copilotVariantEffort = '';
6670
+ let copilotRefusal = null;
6603
6671
  if (
6604
6672
  remoteRequest.remote
6605
6673
  && !useClaudeCloudProvider && !useOpenAIProvider && !useClaudeProvider && !useCursorProvider && !useGrokProvider
@@ -6625,20 +6693,23 @@ export function registerSessionsRoutes(app, deps) {
6625
6693
  effortsFor: (base) => reasoningByModel[base] || reasoningByModel[String(base).toLowerCase()] || null,
6626
6694
  });
6627
6695
  // Another provider's model is answered by the checks below ("requires
6628
- // the Claude provider"), not as an unknown one.
6696
+ // the Claude provider"), not as an unknown one. One that several other
6697
+ // providers list ("claude-opus-5-5": Claude and Cursor) passes those
6698
+ // checks and is refused as unknown after them.
6629
6699
  const otherProviders = mergedModels.some((modelId) => String(modelId || '').trim().toLowerCase() === requestedBootstrapModel.toLowerCase());
6630
6700
  if (copilotCheck.status === 'known' && copilotCheck.model && !isAutoModelId(copilotCheck.model)) {
6631
6701
  copilotBoundModel = copilotCheck.base || copilotCheck.model;
6632
6702
  copilotVariantEffort = copilotCheck.effort || '';
6633
6703
  }
6634
- if (copilotCheck.status === 'unknown' && !otherProviders) {
6635
- return res.status(400).json({
6704
+ if (copilotCheck.status === 'unknown') {
6705
+ copilotRefusal = {
6636
6706
  ok: false,
6637
6707
  error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
6638
6708
  code: 'COPILOT_MODEL_UNAVAILABLE',
6639
6709
  supportedModels: copilotCheck.models,
6640
6710
  ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
6641
- });
6711
+ };
6712
+ if (!otherProviders) return res.status(400).json(copilotRefusal);
6642
6713
  }
6643
6714
  }
6644
6715
  if (useOpenAIProvider && openAISettings?.configured !== true) {
@@ -6798,6 +6869,9 @@ export function registerSessionsRoutes(app, deps) {
6798
6869
  });
6799
6870
  }
6800
6871
  }
6872
+ // No single other provider claims it: a Copilot session with a model
6873
+ // Copilot does not list would be refused at its first send.
6874
+ if (copilotRefusal) return res.status(400).json(copilotRefusal);
6801
6875
 
6802
6876
  const bootstrapProviderType = useClaudeCloudProvider
6803
6877
  ? 'claude-cloud'
@@ -6815,10 +6889,27 @@ export function registerSessionsRoutes(app, deps) {
6815
6889
  // new chat would be stored as the default one and reset the composer. An
6816
6890
  // omitted or unknown mode still falls back to the default rather than 400,
6817
6891
  // because a new chat is worth starting either way.
6818
- const bootstrapRelayMode = normalizeRelayModePreference(req.body?.relayMode ?? req.body?.preferredRelayMode, {
6819
- supportedRelayModes: SUPPORTED_RELAY_MODES,
6820
- fallbackMode: DEFAULT_RELAY_MODE,
6821
- });
6892
+ // Plan-only: a session that starts locked in plan mode. With no
6893
+ // mode named it starts in plan; a mode that is not plan is refused.
6894
+ const bootstrapPlanOnly = Object.prototype.hasOwnProperty.call(req.body || {}, 'planOnly') ? req.body.planOnly : undefined;
6895
+ if (bootstrapPlanOnly !== undefined && typeof bootstrapPlanOnly !== 'boolean') {
6896
+ return res.status(400).json({ ok: false, error: 'planOnly must be true or false', code: 'PLAN_ONLY_INVALID' });
6897
+ }
6898
+ const namedBootstrapMode = req.body?.relayMode ?? req.body?.preferredRelayMode;
6899
+ if (bootstrapPlanOnly === true) {
6900
+ if (!stmts.conversationsHavePlanOnly) {
6901
+ 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' });
6902
+ }
6903
+ if (String(namedBootstrapMode ?? '').trim() && String(namedBootstrapMode).trim().toLowerCase() !== 'plan') {
6904
+ 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' });
6905
+ }
6906
+ }
6907
+ const bootstrapRelayMode = bootstrapPlanOnly === true
6908
+ ? 'plan'
6909
+ : normalizeRelayModePreference(namedBootstrapMode, {
6910
+ supportedRelayModes: SUPPORTED_RELAY_MODES,
6911
+ fallbackMode: DEFAULT_RELAY_MODE,
6912
+ });
6822
6913
  // The New Chat modal's reasoning choice is validated with the same rules the
6823
6914
  // first send would apply, so the composer can restore it from the server
6824
6915
  // instead of guessing from localStorage.
@@ -6896,6 +6987,7 @@ export function registerSessionsRoutes(app, deps) {
6896
6987
  preferredRelayMode: bootstrapRelayMode,
6897
6988
  preferredModel: selectedModel,
6898
6989
  preferredReasoningEffort: bootstrapReasoning.effort,
6990
+ ...(bootstrapPlanOnly === true ? { planOnly: true } : {}),
6899
6991
  updatedAt: now,
6900
6992
  });
6901
6993
  return { boundRuntimeSession, preferences };
@@ -7014,6 +7106,8 @@ export function registerSessionsRoutes(app, deps) {
7014
7106
  preferredRelayMode,
7015
7107
  preferredModel,
7016
7108
  preferredReasoningEffort,
7109
+ // Echoed so the calling relay can tell this relay knows the flag.
7110
+ planOnly: preferences?.planOnly === true,
7017
7111
  configuredWorkspaceRootPath: workspaceRootState?.configuredWorkspaceRootPath || null,
7018
7112
  configuredWorkspaceRootName: workspaceRootState?.configuredWorkspaceRootName || null,
7019
7113
  currentWorkspaceRootPath: workspaceRootState?.currentWorkspaceRootPath || null,
@@ -9127,6 +9221,22 @@ export function registerSessionsRoutes(app, deps) {
9127
9221
  deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9128
9222
  claudeApiCard,
9129
9223
  });
9224
+ // Every tab's Tokens sections, from the token booking.
9225
+ if (tokenUsageService) {
9226
+ appendTokenSections(report.providers, (options) => tokenUsageService.readPeriods(options), {
9227
+ // A tab names its models as its other rows do: the Claude API tab as
9228
+ // its spend rows (the Console's names), DeepSeek with its own labels,
9229
+ // the Claude tab with Claude's; the raw id where there is none.
9230
+ modelLabelFor: (provider) => {
9231
+ if (provider === 'claude-api' && typeof claudeApiPlanUsage?.modelLabel === 'function') {
9232
+ return (model) => claudeApiPlanUsage.modelLabel(model);
9233
+ }
9234
+ if (provider === 'deepseek') return (model) => deepseekModelLabel(model);
9235
+ if (provider === 'claude') return (model) => claudeAttributionModelLabel(model);
9236
+ return null;
9237
+ },
9238
+ });
9239
+ }
9130
9240
 
9131
9241
  // Legacy top-level fields stay alongside `providers` so an older cached
9132
9242
  // client shell keeps working through a PWA update.