@oar-sh/oar 0.9.19 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. package/shared/turn-token-usage.mjs +144 -0
@@ -1,7 +1,14 @@
1
1
  'use strict';
2
2
 
3
- import { persistConversationPreferences } from '../services/conversation-preferences-service.mjs';
3
+ import { persistConversationPreferences, readPlanOnly } from '../services/conversation-preferences-service.mjs';
4
4
  import { fallbackReasoningEffort } from '../services/conversation-model-fallback.mjs';
5
+ import {
6
+ PLAN_BOARD_APPROVE_ONLY,
7
+ isPendingPlanBoard,
8
+ planBoardActionLabel,
9
+ planBoardFollowupMode,
10
+ planBoardFollowupText,
11
+ } from '../../shared/plan-board-actions.mjs';
5
12
 
6
13
  function normalizeId(value) {
7
14
  const text = String(value || '').trim();
@@ -20,34 +27,6 @@ function normalizeBoardType(value) {
20
27
  return text.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
21
28
  }
22
29
 
23
- function labelForAction(actionId) {
24
- const id = String(actionId || '').trim().toLowerCase();
25
- if (id === 'autopilot') return 'Implement in autopilot';
26
- if (id === 'autopilot_fleet') return 'Implement with autopilot fleet';
27
- if (id === 'interactive') return 'Stop here and prompt myself';
28
- if (id === 'exit_only') return 'Stop here';
29
- return id.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()) || 'Select';
30
- }
31
-
32
- function defaultPromptForAction(actionId, board = null) {
33
- const summary = String(board?.body || '').trim();
34
- if (actionId === 'autopilot') {
35
- return `Please implement the approved plan now.\n\nPlan summary:\n${summary}`;
36
- }
37
- if (actionId === 'autopilot_fleet') {
38
- return `Please implement the approved plan using autopilot_fleet where useful.\n\nPlan summary:\n${summary}`;
39
- }
40
- if (actionId === 'interactive') {
41
- return `I approved the plan. Let's proceed interactively with implementation.\n\nPlan summary:\n${summary}`;
42
- }
43
- return '';
44
- }
45
-
46
- function modeForAction(actionId, fallbackMode) {
47
- if (actionId === 'autopilot' || actionId === 'autopilot_fleet') return 'autopilot';
48
- if (actionId === 'interactive') return 'agent';
49
- return fallbackMode;
50
- }
51
30
 
52
31
  function normalizeBoardActions(rawActions) {
53
32
  const input = Array.isArray(rawActions) ? rawActions : [];
@@ -61,7 +40,7 @@ function normalizeBoardActions(rawActions) {
61
40
  const id = String(item.id || item.actionId || item.value || '').trim().toLowerCase();
62
41
  if (!id || seen.has(id)) continue;
63
42
  seen.add(id);
64
- const label = String(item.label || item.title || labelForAction(id)).trim().slice(0, 120) || labelForAction(id);
43
+ const label = String(item.label || item.title || planBoardActionLabel(id)).trim().slice(0, 120) || planBoardActionLabel(id);
65
44
  const mode = normalizeId(item.mode);
66
45
  const prompt = normalizeBoardText(item.prompt || '', '');
67
46
  normalized.push({
@@ -99,6 +78,7 @@ export function registerRelayBoardRoutes(app, deps) {
99
78
  DEFAULT_MODEL,
100
79
  formatRelayBoardRow,
101
80
  pushDispatchService,
81
+ relayEventService,
102
82
  } = deps;
103
83
 
104
84
  /**
@@ -201,7 +181,12 @@ export function registerRelayBoardRoutes(app, deps) {
201
181
  const boardId = uuidv4();
202
182
  const relayMode = normalizeRelayMode(mode || q.relay_mode) || DEFAULT_RELAY_MODE;
203
183
  const normalizedActions = normalizeBoardActions(actions);
204
- const normalizedRecommended = normalizeId(recommendedAction);
184
+ // A plan-only session's plan card recommends "Approve plan", whatever the
185
+ // worker or the Copilot CLI recommended.
186
+ const boardConversationId = normalizeId(conversationId) || q.conversation_id;
187
+ const planOnly = normalizedBoardType === 'plan_ready'
188
+ && readPlanOnly(stmts.getConvAnyStatus?.get?.(boardConversationId));
189
+ const normalizedRecommended = planOnly ? PLAN_BOARD_APPROVE_ONLY : normalizeId(recommendedAction);
205
190
  const boardContext = parseContext(context);
206
191
 
207
192
  stmts.insertBoard.run(
@@ -240,23 +225,43 @@ export function registerRelayBoardRoutes(app, deps) {
240
225
  }
241
226
 
242
227
  const board = formatRelayBoardRow(row);
228
+ const conversationRow = stmts.getConvAnyStatus?.get?.(String(board?.conversationId || '').trim()) || null;
229
+ if (conversationRow && String(conversationRow.status || '').trim() === 'deleted') {
230
+ return res.status(404).json({ error: 'Conversation not found' });
231
+ }
243
232
  const actions = Array.isArray(board?.actions) ? board.actions : [];
244
- const selected = actions.find((item) => String(item?.id || '').trim().toLowerCase() === actionId.toLowerCase());
233
+ const chosen = actionId.toLowerCase();
234
+ // "Approve plan" is taken on any pending plan card, also one stored
235
+ // before the button existed.
236
+ const selected = actions.find((item) => String(item?.id || '').trim().toLowerCase() === chosen)
237
+ || (chosen === PLAN_BOARD_APPROVE_ONLY && isPendingPlanBoard(board)
238
+ ? { id: PLAN_BOARD_APPROVE_ONLY, label: planBoardActionLabel(PLAN_BOARD_APPROVE_ONLY), mode: planBoardFollowupMode(PLAN_BOARD_APPROVE_ONLY) }
239
+ : null);
245
240
  if (!selected) return res.status(400).json({ error: 'Unknown board action' });
246
241
 
247
242
  const now = new Date().toISOString();
248
- const shouldDismiss = actionId.toLowerCase() === 'exit_only';
249
- if (shouldDismiss) {
250
- stmts.dismissBoard.run(actionId, now, now, boardId);
251
- } else {
252
- stmts.markBoardAction.run(actionId, now, now, boardId);
243
+ const shouldDismiss = chosen === 'exit_only';
244
+ const approveOnly = chosen === PLAN_BOARD_APPROVE_ONLY;
245
+ // Only a pending card is settled: a second press (here or in the Inbox)
246
+ // finds it settled already and queues nothing.
247
+ const settledRow = shouldDismiss
248
+ ? stmts.dismissBoard.run(chosen, now, now, boardId)
249
+ : stmts.markBoardAction.run(chosen, now, now, boardId);
250
+ if (Number(settledRow?.changes) !== 1) {
251
+ const current = stmts.getBoard.get(boardId);
252
+ return res.status(409).json({ error: `Board already ${current?.status || 'settled'}`, board: formatRelayBoardRow(current) });
253
253
  }
254
254
 
255
255
  let queuedMessageId = null;
256
256
  if (!shouldDismiss) {
257
- const followupPrompt = normalizeBoardText(selected.prompt || defaultPromptForAction(actionId.toLowerCase(), board), '');
257
+ // approve_only says exactly its line; the others may carry their own.
258
+ const followupPrompt = normalizeBoardText(approveOnly ? planBoardFollowupText(chosen) : (selected.prompt || planBoardFollowupText(chosen)), '');
258
259
  if (followupPrompt) {
259
- const relayMode = normalizeRelayMode(selected.mode || modeForAction(actionId.toLowerCase(), board.mode)) || DEFAULT_RELAY_MODE;
260
+ // approve_only runs this one turn in agent mode (plan mode blocks
261
+ // the writes its plan-only steps need); the session stays in plan.
262
+ const relayMode = normalizeRelayMode(approveOnly
263
+ ? planBoardFollowupMode(chosen)
264
+ : (selected.mode || planBoardFollowupMode(chosen) || board.mode)) || DEFAULT_RELAY_MODE;
260
265
  const convId = String(board?.conversationId || '').trim();
261
266
  const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(convId) || null;
262
267
  const latestModel = stmts.getLatestConversationModel?.get?.(convId)?.model || null;
@@ -323,11 +328,21 @@ export function registerRelayBoardRoutes(app, deps) {
323
328
  timestamp: now,
324
329
  },
325
330
  });
326
- followChosenMode(convId, relayMode, now);
331
+ if (!approveOnly) followChosenMode(convId, relayMode, now);
327
332
  }
328
333
  }
329
334
 
330
335
  const updated = formatRelayBoardRow(stmts.getBoard.get(boardId));
336
+ try {
337
+ relayEventService?.noteBoardSettled?.(String(updated?.conversationId || board?.conversationId || ''), {
338
+ boardId,
339
+ boardType: updated?.boardType || board?.boardType || null,
340
+ action: chosen,
341
+ actionLabel: String(selected.label || '').trim() || planBoardActionLabel(chosen),
342
+ title: updated?.title || board?.title || '',
343
+ queuedMessageId,
344
+ });
345
+ } catch {}
331
346
  io.emit('relay_board_updated', { board: updated });
332
347
  io.emit('relay_board_changed', { conversationId: updated?.conversationId || null, boardId });
333
348
  return res.json({
@@ -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
 
@@ -2243,6 +2246,9 @@ export function registerSessionsRoutes(app, deps) {
2243
2246
  // The once-per-episode notes for stopped self-started turns on the key
2244
2247
  // (plan-usage-claude-api.mjs): a saved setting starts every chat afresh.
2245
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,
2246
2252
  reconcileUnstartedConversationProviders = async () => ({
2247
2253
  updatedUnstartedConversations: 0,
2248
2254
  skippedStartedConversations: 0,
@@ -2580,6 +2586,8 @@ export function registerSessionsRoutes(app, deps) {
2580
2586
  if (findDeepSeekUsageCheckpointsTable.get(DEEPSEEK_USAGE_CHECKPOINTS_TABLE)) {
2581
2587
  db.prepare(`DELETE FROM ${DEEPSEEK_USAGE_CHECKPOINTS_TABLE} WHERE conversation_id = ?`).run(conversationId);
2582
2588
  }
2589
+ // Its turns' tokens go with it; the day sums behind Check Usage stay.
2590
+ tokenUsageService?.forgetConversation?.(conversationId);
2583
2591
  // The share access log goes with it; the share rows follow the foreign key.
2584
2592
  shares().forgetConversation(conversationId);
2585
2593
  db.prepare(`DELETE FROM conversations WHERE id = ?`).run(conversationId);
@@ -3398,6 +3406,7 @@ export function registerSessionsRoutes(app, deps) {
3398
3406
  origin: parseRemoteRelayOriginJson(r.origin_json),
3399
3407
  // The owner let this agent-started session's agents start sessions.
3400
3408
  mayCreateSessions: readMayCreateSessions(r),
3409
+ planOnly: readPlanOnly(r),
3401
3410
  // Set on Claude Cloud conversations: repository, branch, session link.
3402
3411
  cloud: buildConversationCloudPayload({
3403
3412
  cloudSourceJson: r.cloud_source_json,
@@ -4158,6 +4167,7 @@ export function registerSessionsRoutes(app, deps) {
4158
4167
  draftUpdatedByClientId: conv.draft_updated_by_client_id || null,
4159
4168
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4160
4169
  mayCreateSessions: readMayCreateSessions(conv),
4170
+ planOnly: readPlanOnly(conv),
4161
4171
  cloud: conversationCloudPayload(conv, runtimeSession),
4162
4172
  group: conversationGroupPayload(conversationId),
4163
4173
  groupPosition: conversationGroupPositionOf(conv),
@@ -4864,6 +4874,7 @@ export function registerSessionsRoutes(app, deps) {
4864
4874
  // Set on conversations another relay's agent created ("via <relay>").
4865
4875
  origin: parseRemoteRelayOriginJson(conv.origin_json),
4866
4876
  mayCreateSessions: readMayCreateSessions(conv),
4877
+ planOnly: readPlanOnly(conv),
4867
4878
  // Set on Claude Cloud conversations: repository, branch, session link.
4868
4879
  cloud: conversationCloudPayload(conv, runtimeSession),
4869
4880
  // The session group it is in, with the other members; null when none.
@@ -5086,6 +5097,7 @@ export function registerSessionsRoutes(app, deps) {
5086
5097
  ? { thinkingDisplay: parseThinkingDisplay(body.thinkingDisplay) }
5087
5098
  : {}),
5088
5099
  ...(mentions('mayCreateSessions') ? { mayCreateSessions: body.mayCreateSessions === true } : {}),
5100
+ ...(mentions('planOnly') ? { planOnly: body.planOnly === true } : {}),
5089
5101
  updatedAt: now,
5090
5102
  createIfMissing: !existing,
5091
5103
  createTitle: 'Session',
@@ -5099,6 +5111,7 @@ export function registerSessionsRoutes(app, deps) {
5099
5111
  thinkingEnabled: persisted.thinkingEnabled ?? null,
5100
5112
  thinkingDisplay: persisted.thinkingDisplay ?? null,
5101
5113
  mayCreateSessions: persisted.mayCreateSessions === true,
5114
+ planOnly: persisted.planOnly === true,
5102
5115
  updatedAt: persisted.updatedAt,
5103
5116
  senderClientId,
5104
5117
  };
@@ -5128,6 +5141,26 @@ export function registerSessionsRoutes(app, deps) {
5128
5141
  return res.status(503).json({ error: 'This setting is unavailable until the relay restarts (its storage is missing).' });
5129
5142
  }
5130
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
+ }
5131
5164
 
5132
5165
  const persisted = applyConversationPreferences({ conversationId, existing, body: req.body || {}, senderClientId });
5133
5166
  return res.json({ ok: true, ...persisted });
@@ -5168,6 +5201,7 @@ export function registerSessionsRoutes(app, deps) {
5168
5201
  thinking_enabled: parseThinkingEnabled(conv.thinking_enabled),
5169
5202
  thinking_display: parseThinkingDisplay(conv.thinking_display),
5170
5203
  cwd: String(workspace?.currentWorkspaceRootPath || workspace?.configuredWorkspaceRootPath || conv.configured_workspace_root_path || '').trim() || null,
5204
+ planOnly: readPlanOnly(conv),
5171
5205
  };
5172
5206
  }
5173
5207
 
@@ -5201,7 +5235,7 @@ export function registerSessionsRoutes(app, deps) {
5201
5235
  * body }`. The plan holds what changes (`changed`, in the order of the
5202
5236
  * contract's settings) and how.
5203
5237
  */
5204
- function planSessionSettings(conversation, raw) {
5238
+ function planSessionSettings(conversation, raw, { remote = false } = {}) {
5205
5239
  const conversationId = String(conversation.id);
5206
5240
  const fail = (status, error, extra = {}) => ({ ok: false, status, body: { ok: false, error, ...extra } });
5207
5241
  const settingsInput = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
@@ -5236,6 +5270,30 @@ export function registerSessionsRoutes(app, deps) {
5236
5270
  change('mode');
5237
5271
  }
5238
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
+ }
5239
5297
  if (wanted.auto_compact_window !== undefined) {
5240
5298
  const window = wanted.auto_compact_window === 'auto' ? null : parseAutoCompactWindow(wanted.auto_compact_window);
5241
5299
  if (window !== parseAutoCompactWindow(conversation.auto_compact_window)) {
@@ -5422,7 +5480,7 @@ export function registerSessionsRoutes(app, deps) {
5422
5480
  }
5423
5481
  const conversationId = String(conversation.id);
5424
5482
  const body = req.body && typeof req.body === 'object' ? req.body : {};
5425
- const planned = planSessionSettings(conversation, body.settings);
5483
+ const planned = planSessionSettings(conversation, body.settings, { remote: remoteRequest.remote === true });
5426
5484
  if (!planned.ok) return res.status(planned.status).json(planned.body);
5427
5485
  const { plan } = planned;
5428
5486
  const createdBy = parseRemoteRelayOriginJson(conversation.origin_json);
@@ -6074,6 +6132,7 @@ export function registerSessionsRoutes(app, deps) {
6074
6132
  stopOverrides: sessionWorkerStopOverrides,
6075
6133
  });
6076
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'}).`);
6077
6136
  return {
6078
6137
  statusCode: 409,
6079
6138
  body: {
@@ -6220,6 +6279,7 @@ export function registerSessionsRoutes(app, deps) {
6220
6279
  stopOverrides: sessionWorkerStopOverrides,
6221
6280
  });
6222
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'}).`);
6223
6283
  return {
6224
6284
  statusCode: 409,
6225
6285
  body: {
@@ -6829,10 +6889,27 @@ export function registerSessionsRoutes(app, deps) {
6829
6889
  // new chat would be stored as the default one and reset the composer. An
6830
6890
  // omitted or unknown mode still falls back to the default rather than 400,
6831
6891
  // 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
- });
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
+ });
6836
6913
  // The New Chat modal's reasoning choice is validated with the same rules the
6837
6914
  // first send would apply, so the composer can restore it from the server
6838
6915
  // instead of guessing from localStorage.
@@ -6910,6 +6987,7 @@ export function registerSessionsRoutes(app, deps) {
6910
6987
  preferredRelayMode: bootstrapRelayMode,
6911
6988
  preferredModel: selectedModel,
6912
6989
  preferredReasoningEffort: bootstrapReasoning.effort,
6990
+ ...(bootstrapPlanOnly === true ? { planOnly: true } : {}),
6913
6991
  updatedAt: now,
6914
6992
  });
6915
6993
  return { boundRuntimeSession, preferences };
@@ -7028,6 +7106,8 @@ export function registerSessionsRoutes(app, deps) {
7028
7106
  preferredRelayMode,
7029
7107
  preferredModel,
7030
7108
  preferredReasoningEffort,
7109
+ // Echoed so the calling relay can tell this relay knows the flag.
7110
+ planOnly: preferences?.planOnly === true,
7031
7111
  configuredWorkspaceRootPath: workspaceRootState?.configuredWorkspaceRootPath || null,
7032
7112
  configuredWorkspaceRootName: workspaceRootState?.configuredWorkspaceRootName || null,
7033
7113
  currentWorkspaceRootPath: workspaceRootState?.currentWorkspaceRootPath || null,
@@ -9141,6 +9221,22 @@ export function registerSessionsRoutes(app, deps) {
9141
9221
  deepseekActuals: deepseekEnabled ? readDeepSeekActuals() : null,
9142
9222
  claudeApiCard,
9143
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
+ }
9144
9240
 
9145
9241
  // Legacy top-level fields stay alongside `providers` so an older cached
9146
9242
  // client shell keeps working through a PWA update.