@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
@@ -75,6 +75,12 @@ export function createRemoteRelayRepository(db) {
75
75
  const getMayCreateSessions = hasMayCreateColumn
76
76
  ? db.prepare(`SELECT may_create_sessions FROM conversations WHERE id = ?`)
77
77
  : null;
78
+ // Plan-only (migration 0035). Without the column no session is.
79
+ const hasPlanOnlyColumn = db.prepare(`PRAGMA table_info(conversations)`).all()
80
+ .some((column) => column.name === 'plan_only');
81
+ const getPlanOnly = hasPlanOnlyColumn
82
+ ? db.prepare(`SELECT plan_only FROM conversations WHERE id = ?`)
83
+ : null;
78
84
 
79
85
  return {
80
86
  hasUnlock(conversationId, remoteRelayId) {
@@ -161,6 +167,10 @@ export function createRemoteRelayRepository(db) {
161
167
  mayCreateSessions(conversationId) {
162
168
  return Number(getMayCreateSessions?.get(String(conversationId || ''))?.may_create_sessions) === 1;
163
169
  },
170
+ /** Is this conversation plan-only (agents cannot take it out of plan mode)? */
171
+ planOnly(conversationId) {
172
+ return Number(getPlanOnly?.get(String(conversationId || ''))?.plan_only) === 1;
173
+ },
164
174
  };
165
175
  }
166
176
 
@@ -45,6 +45,10 @@ export function createSessionRepository(db) {
45
45
  const conversationsHaveMayCreateSessions = db.prepare(`PRAGMA table_info(conversations)`).all()
46
46
  .some((column) => String(column?.name || '').trim() === 'may_create_sessions');
47
47
  const conversationMayCreateSelect = conversationsHaveMayCreateSessions ? 'c.may_create_sessions' : 'NULL AS may_create_sessions';
48
+ // Plan-only (migration 0035), the same way.
49
+ const conversationsHavePlanOnly = db.prepare(`PRAGMA table_info(conversations)`).all()
50
+ .some((column) => String(column?.name || '').trim() === 'plan_only');
51
+ const conversationPlanOnlySelect = conversationsHavePlanOnly ? 'c.plan_only' : 'NULL AS plan_only';
48
52
  return {
49
53
  // conversations
50
54
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -53,7 +57,8 @@ export function createSessionRepository(db) {
53
57
  listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
54
58
  runtimeSessionsSupportProviders,
55
59
  conversationsHaveMayCreateSessions,
56
- listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, ${conversationMayCreateSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
60
+ conversationsHavePlanOnly,
61
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, ${conversationCloudSelect}, ${conversationGroupSelect}, ${conversationMayCreateSelect}, ${conversationPlanOnlySelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = ?) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
57
62
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
58
63
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
59
64
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -15,6 +15,7 @@ import {
15
15
  } from '../services/relay-queue-gate-service.mjs';
16
16
  import {
17
17
  persistConversationModelPreference as persistConversationModelPreferenceTx,
18
+ readPlanOnly,
18
19
  } from '../services/conversation-preferences-service.mjs';
19
20
  import { fallbackReasoningEffort, inheritedConversationModel } from '../services/conversation-model-fallback.mjs';
20
21
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
@@ -56,7 +57,11 @@ import { readBefore, readTail, shapeSnapshot, taskOutputSource } from '../servic
56
57
  import { taskKindOf } from '../services/session-activity-tracker.mjs';
57
58
  import { ANSWERED_ELSEWHERE_KIND, STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
58
59
  import { isLiveResend } from '../services/steer-resend.mjs';
59
- import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
60
+ import {
61
+ checkRequestedProviderModel,
62
+ resolveProviderModelSelection,
63
+ unknownProviderModelText,
64
+ } from '../services/provider-model-selection.mjs';
60
65
  import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
61
66
  import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
62
67
  import {
@@ -82,8 +87,11 @@ import {
82
87
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
83
88
  import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
84
89
  import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
90
+ import { normalizeTurnTokenUsageReport } from '../../shared/turn-token-usage.mjs';
85
91
  import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
86
92
  import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
93
+ import { planBoundaryHoldSql } from '../repositories/message-repository.mjs';
94
+ import { PLAN_ONLY_REFUSAL, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
87
95
  import {
88
96
  admitRemoteRelayRequest,
89
97
  readRemoteRelayRequest,
@@ -917,6 +925,23 @@ export function resolveResponderConversationMismatch({
917
925
  };
918
926
  }
919
927
 
928
+ /**
929
+ * Whether a pending row must wait for its conversation's live turn to end
930
+ * instead of being handed to the worker, which would steer it into that turn.
931
+ * A steer switches the live turn's mode (the Claude worker re-applies the
932
+ * permission mode on every delivery), so across plan mode it would change
933
+ * what the running turn may do: an "Approve plan" follow-up would give a
934
+ * running plan turn write access, and a plan message would take it from a
935
+ * running write turn. Rows on the same side of plan mode steer as before.
936
+ */
937
+ export function crossesPlanBoundaryOfLiveTurn({ stmts, row } = {}) {
938
+ const conversationId = String(row?.conversation_id || '').trim();
939
+ if (!conversationId || typeof stmts?.listProcessingQueueByConversation?.all !== 'function') return false;
940
+ const isPlan = (mode) => String(mode || '').trim().toLowerCase() === 'plan';
941
+ const pendingIsPlan = isPlan(row.relay_mode);
942
+ return stmts.listProcessingQueueByConversation.all(conversationId)
943
+ .some((live) => String(live?.id || '') !== String(row.id || '') && isPlan(live?.relay_mode) !== pendingIsPlan);
944
+ }
920
945
  export function dequeuePendingMessage({
921
946
  db,
922
947
  stmts,
@@ -966,6 +991,10 @@ export function dequeuePendingMessage({
966
991
  : stmts.findPending.get(currentIso);
967
992
  }
968
993
  if (!next) return null;
994
+ // Across plan mode, a row waits for the live turn instead of steering in.
995
+ // The claim statements leave such rows out (planBoundaryHoldSql); this is
996
+ // the fail-closed backstop for one that does not.
997
+ if (crossesPlanBoundaryOfLiveTurn({ stmts, row: next })) return null;
969
998
  // One processing attempt = one attempt_id. Minted here for every claim —
970
999
  // even for callers that never echo it back — so finalization, requeue, and
971
1000
  // question writes can be fenced to the attempt that actually owns the row.
@@ -2045,6 +2074,8 @@ export function registerMessagesRoutes(app, deps) {
2045
2074
  claudeBillingSwitchService = null,
2046
2075
  // The Claude API booking (claude-api-usage-service.mjs).
2047
2076
  claudeApiUsageService = null,
2077
+ // Tokens per turn for every provider (token-usage-service.mjs).
2078
+ tokenUsageService = null,
2048
2079
  // The delivery fence's runtime half for GET /api/pending, as on the
2049
2080
  // socket: the Claude API claim check (null: no refusal) and what a fenced
2050
2081
  // row does (the swap, or fail closed).
@@ -2636,6 +2667,7 @@ export function registerMessagesRoutes(app, deps) {
2636
2667
  AND rs.sdk_session_id = ?
2637
2668
  )
2638
2669
  AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
2670
+ AND ${planBoundaryHoldSql('q')}
2639
2671
  ORDER BY q.timestamp ASC, q.rowid ASC
2640
2672
  LIMIT 1
2641
2673
  `);
@@ -4800,6 +4832,12 @@ export function registerMessagesRoutes(app, deps) {
4800
4832
  ? normalizeRelayMode(existingConversation.preferred_relay_mode)
4801
4833
  : null;
4802
4834
  const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
4835
+ // Plan-only: an agent's send that names another mode is refused
4836
+ // and nothing is queued (nor a billing switched). Without a mode it runs
4837
+ // in the session's own. The owner's own sends are not limited.
4838
+ if (remoteRequest.remote && readPlanOnly(existingConversation) && planOnlyRefusesMode(relayMode || mode)) {
4839
+ return res.status(409).json({ ...PLAN_ONLY_REFUSAL, conversationId });
4840
+ }
4803
4841
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
4804
4842
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4805
4843
  const requestedAttachmentCount = Math.min(
@@ -5019,6 +5057,13 @@ export function registerMessagesRoutes(app, deps) {
5019
5057
  code: 'OPENAI_MODEL_REQUIRES_NEW_CONVERSATION',
5020
5058
  });
5021
5059
  }
5060
+ // A Copilot conversation is bound too, for the model-id guards below only:
5061
+ // Copilot resells Claude and GPT models under ids of its own
5062
+ // ("claude-opus-5.5"), so a Claude-form id another provider lists
5063
+ // ("claude-opus-5-5") is a model Copilot lacks, answered by the Copilot
5064
+ // check further down, never a switch to Cursor, Grok or DeepSeek. (It is
5065
+ // not in runtimeBoundToExplicitProvider: that one also picks the catalog.)
5066
+ const runtimeBoundForModelGuards = runtimeBoundToExplicitProvider || runtimeProviderType === 'github';
5022
5067
  // Cursor conversations are bootstrap-created only, so an existing
5023
5068
  // github/openai/claude conversation cannot cross to the Cursor provider
5024
5069
  // mid-conversation. Cursor settings are read lazily so the hot path stays
@@ -5028,7 +5073,7 @@ export function registerMessagesRoutes(app, deps) {
5028
5073
  let requestTargetsCursor = requestedProviderType === 'cursor';
5029
5074
  if (
5030
5075
  !requestTargetsCursor
5031
- && !runtimeBoundToExplicitProvider
5076
+ && !runtimeBoundForModelGuards
5032
5077
  && requestedOpenAIModel
5033
5078
  && !requestedModelAvailableInGitHub
5034
5079
  ) {
@@ -5053,7 +5098,7 @@ export function registerMessagesRoutes(app, deps) {
5053
5098
  let requestTargetsGrok = requestedProviderType === 'grok';
5054
5099
  if (
5055
5100
  !requestTargetsGrok
5056
- && !runtimeBoundToExplicitProvider
5101
+ && !runtimeBoundForModelGuards
5057
5102
  && requestedOpenAIModel
5058
5103
  && !requestedModelAvailableInGitHub
5059
5104
  ) {
@@ -5079,7 +5124,7 @@ export function registerMessagesRoutes(app, deps) {
5079
5124
  let requestTargetsDeepSeek = requestedProviderType === 'deepseek';
5080
5125
  if (
5081
5126
  !requestTargetsDeepSeek
5082
- && !runtimeBoundToExplicitProvider
5127
+ && !runtimeBoundForModelGuards
5083
5128
  && requestedOpenAIModel
5084
5129
  && !requestedModelAvailableInGitHub
5085
5130
  ) {
@@ -5397,6 +5442,24 @@ export function registerMessagesRoutes(app, deps) {
5397
5442
  error: deepseekModel ? null : 'DeepSeek model is not configured',
5398
5443
  }
5399
5444
  : githubModelResolution)))));
5445
+ // A model Copilot does not list is named with its close matches, as the
5446
+ // bootstrap names it: "claude-opus-5-5" (the Claude form) gets "did you
5447
+ // mean 'claude-opus-5.5'?". Refused before anything is stored.
5448
+ if (!modelResolution.ok && modelResolution === githubModelResolution && explicitModelGiven) {
5449
+ const copilotCheck = checkRequestedProviderModel({
5450
+ requestedModel: requestedOpenAIModel,
5451
+ provider: 'github',
5452
+ models: Array.isArray(githubModelResolution.available) ? githubModelResolution.available : [],
5453
+ });
5454
+ if (copilotCheck.status === 'unknown') {
5455
+ return res.status(400).json({
5456
+ error: unknownProviderModelText({ model: copilotCheck.model, provider: 'github', suggestions: copilotCheck.suggestions, models: copilotCheck.models }),
5457
+ code: 'COPILOT_MODEL_UNAVAILABLE',
5458
+ supportedModels: copilotCheck.models,
5459
+ ...(copilotCheck.suggestions.length ? { suggestedModels: copilotCheck.suggestions } : {}),
5460
+ });
5461
+ }
5462
+ }
5400
5463
  if (!modelResolution.ok) return res.status(400).json({ error: modelResolution.error, supportedModels: modelResolution.available || [] });
5401
5464
  const requestedModel = String(modelResolution.model || '').trim();
5402
5465
  const requestedAutoModel = requestedModel.toLowerCase() === AUTO_MODEL_SENTINEL;
@@ -6582,6 +6645,59 @@ export function registerMessagesRoutes(app, deps) {
6582
6645
  res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
6583
6646
  });
6584
6647
 
6648
+ // POST /api/turn-token-usage — every worker reports, after a turn, the
6649
+ // turn's tokens per model (`models`), or a Claude cloud worker the
6650
+ // session's running totals (`sessionTotals`), keyed by turn, run and model
6651
+ // (shared/turn-token-usage.mjs). Advisory and idempotent: a retried or
6652
+ // reordered report books only what it adds (token-usage-service.mjs).
6653
+ // Who ran the turn is the relay's to say: the chat's provider, and for a
6654
+ // Claude chat the posting worker's registered kind (the billing can switch
6655
+ // between turns). A body that says otherwise is logged. A deleted chat
6656
+ // takes no report, so a late retry cannot book its turn a second time; an
6657
+ // archived one does.
6658
+ app.post('/api/turn-token-usage', auth, (req, res) => {
6659
+ touchCli();
6660
+ if (!tokenUsageService) return res.status(501).json({ error: 'Token usage storage is unavailable' });
6661
+ const parsed = normalizeTurnTokenUsageReport(req.body);
6662
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6663
+ const { report } = parsed;
6664
+ const conversationId = report.conversationId;
6665
+ if (!stmts.getConv?.get?.(conversationId)) return res.status(404).json({ error: 'Conversation not found' });
6666
+ // The binding's provider wins over the report's. A chat whose binding is
6667
+ // gone for a moment (a cache rebuild) still books, as the provider the
6668
+ // report states.
6669
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
6670
+ const chatProvider = String(runtimeSession?.provider_type || report.provider || '').trim().toLowerCase();
6671
+ let provider = chatProvider;
6672
+ let billing = '';
6673
+ if (chatProvider === 'openai') {
6674
+ // A chat on the user's own OpenAI key runs on the Copilot SDK.
6675
+ provider = 'github';
6676
+ billing = 'byok-openai';
6677
+ } else if (chatProvider === 'github') {
6678
+ billing = report.billing;
6679
+ } else if (normalizeClaudeBilling(chatProvider)) {
6680
+ const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
6681
+ const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
6682
+ // The worker that ran the turn, then the chat's billing; the report's
6683
+ // own word only when the chat has no binding.
6684
+ provider = registeredKind || normalizeClaudeBilling(chatProvider);
6685
+ } else if (!['claude-cloud', 'deepseek', 'cursor', 'grok'].includes(chatProvider)) {
6686
+ return res.status(409).json({ error: 'Conversation keeps no token usage' });
6687
+ }
6688
+ if (report.provider && report.provider !== provider) {
6689
+ console.warn(`[${ts()}] token usage conv=${conversationId.slice(0, 8)} stated=${report.provider} booked=${provider}`);
6690
+ }
6691
+ if (report.sessionTotals && provider !== 'claude-cloud') {
6692
+ return res.status(400).json({ error: 'sessionTotals is for Claude cloud only' });
6693
+ }
6694
+ const booked = report.sessionTotals
6695
+ ? tokenUsageService.recordCloudSessionTotals({ conversationId, provider, billing, report })
6696
+ : tokenUsageService.recordTurn({ conversationId, provider, billing, report });
6697
+ if (!booked) return res.status(501).json({ error: 'Token usage storage is unavailable' });
6698
+ res.json({ ok: true, provider, billing, models: booked.map((entry) => entry.model) });
6699
+ });
6700
+
6585
6701
  // POST /api/claude-plan-usage — Claude worker reports the session's
6586
6702
  // structured /usage data (plan rate-limit windows + session cost totals).
6587
6703
  //
@@ -27,12 +27,15 @@
27
27
  // relay from its Inbox: asks only;
28
28
  // an approval or any other card is
29
29
  // never answered over the pairing
30
- // GET /api/relay-asks/remote/inbox what its Inbox shows of this relay
30
+ // GET /api/relay-asks/remote/inbox what its Inbox shows of this relay;
31
+ // `?push=1`: it can push, and each
32
+ // card a turn waits on says who does
33
+ // (`notice`)
31
34
  //
32
35
  // Every text of an ask or card is an agent's: it goes out as data, and the
33
36
  // browser shows it as plain text.
34
37
 
35
- import { REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
38
+ import { REMOTE_INBOX_CLAIMS, REMOTE_INBOX_NOTICES, REMOTE_RELAY_ERROR_CODES } from '../../shared/remote-relay-contract.mjs';
36
39
  import { RELAY_ASK_LIMITS, relayQuestionKind } from '../../shared/relay-asks.mjs';
37
40
  import { schemaFields } from '../../shared/question-schema.mjs';
38
41
  import { findOriginRemoteRelay, admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
@@ -53,6 +56,8 @@ const RELAY_ASK_ERROR_STATUS = Object.freeze({
53
56
  const INBOX_REMOTE_TIMEOUT_MS = 5_000;
54
57
  // Cards answered in the last day are listed under the divider, at most these.
55
58
  const INBOX_ANSWERED_CARDS_MAX = 50;
59
+ // Plan cards, pending (one per session) and settled in the last day, at most these each.
60
+ const INBOX_PLAN_CARDS_MAX = 50;
56
61
  // Registry states that mean "do not even try" for the Inbox.
57
62
  const UNREACHABLE_STATES = new Set(['offline', 'unauthorized']);
58
63
 
@@ -90,7 +95,15 @@ export function inboxItemOfCard(question, session, { forRemote = false } = {}) {
90
95
  createdAt: question?.createdAt || null,
91
96
  ...(question?.status === 'answered' ? { answeredAt: question?.answeredAt || null } : {}),
92
97
  };
93
- if (forRemote && kind === 'approval') return { ...base, question: '' };
98
+ // Its kind and summary say what it is about, never what it would send.
99
+ if (forRemote && kind === 'approval') {
100
+ return {
101
+ ...base,
102
+ question: '',
103
+ ...(toText(context.variant) ? { variant: toText(context.variant) } : {}),
104
+ ...(toText(context.summary) ? { summary: toText(context.summary) } : {}),
105
+ };
106
+ }
94
107
  const fields = schemaFields(question?.requestSchema);
95
108
  return {
96
109
  ...base,
@@ -107,6 +120,37 @@ export function inboxItemOfCard(question, session, { forRemote = false } = {}) {
107
120
  };
108
121
  }
109
122
 
123
+ /**
124
+ * One Inbox item of a plan card (a formatted relay board). A paired
125
+ * relay's Inbox gets its title and status alone, "Open there": a card is
126
+ * never answered over the pairing. `question` repeats the title for one
127
+ * that does not know plan items (0.9.19 shows it as a card's question).
128
+ */
129
+ export function inboxItemOfPlanBoard(board, session, { forRemote = false } = {}) {
130
+ const title = toText(board?.title) || 'Plan ready for review';
131
+ const status = toText(board?.status) || 'pending';
132
+ const base = {
133
+ id: toText(board?.id),
134
+ kind: 'plan',
135
+ session,
136
+ title,
137
+ status,
138
+ createdAt: board?.createdAt || null,
139
+ };
140
+ if (forRemote) return { ...base, question: title };
141
+ return {
142
+ ...base,
143
+ ...(status !== 'pending' ? { actedAt: board?.actedAt || null } : {}),
144
+ body: String(board?.body || ''),
145
+ actions: (Array.isArray(board?.actions) ? board.actions : [])
146
+ .map((action) => ({ id: toText(action?.id), label: toText(action?.label) || toText(action?.id) }))
147
+ .filter((action) => action.id),
148
+ recommendedAction: toText(board?.recommendedAction) || null,
149
+ planOnly: board?.planOnly === true,
150
+ selectedAction: toText(board?.selectedAction) || null,
151
+ };
152
+ }
153
+
110
154
  /** One Inbox item of an ask (relay-ask-service.mjs shape). */
111
155
  export function inboxItemOfAsk(ask) {
112
156
  const {
@@ -139,6 +183,8 @@ export function registerRelayAskRoutes(app, deps) {
139
183
  db,
140
184
  stmts,
141
185
  formatQuestionRow,
186
+ // Plan cards (relay boards) in the Inbox, formatted as their routes do.
187
+ formatRelayBoardRow = null,
142
188
  relayAskService = null,
143
189
  relayAskLinkService = null,
144
190
  remoteRelayInbound = null,
@@ -146,6 +192,9 @@ export function registerRelayAskRoutes(app, deps) {
146
192
  // calls them (remote-relay-client.mjs): the Inbox of paired relays.
147
193
  remoteRelayRegistry = null,
148
194
  remoteRelayClient = null,
195
+ // push-dispatch-service.mjs questionNotice: whether this relay's own push
196
+ // of a card reached a device.
197
+ questionNotice = null,
149
198
  now = () => Date.now(),
150
199
  } = deps;
151
200
 
@@ -154,6 +203,26 @@ export function registerRelayAskRoutes(app, deps) {
154
203
  SELECT * FROM relay_questions WHERE status = 'answered' AND answered_at >= ? ORDER BY answered_at DESC LIMIT ?
155
204
  `) || null;
156
205
 
206
+ // Plan cards: of each session that is neither deleted nor
207
+ // archived the newest pending one (an older one stays in its session),
208
+ // and the ones settled in the last day.
209
+ const planBoardSessionFilter = `c.status != 'deleted' AND COALESCE(c.archived, 0) = 0`;
210
+ const pendingPlanBoardsStmt = db?.prepare?.(`
211
+ SELECT b.* FROM relay_boards b JOIN conversations c ON c.id = b.conversation_id
212
+ WHERE b.status = 'pending' AND b.board_type = 'plan_ready' AND ${planBoardSessionFilter}
213
+ AND NOT EXISTS (
214
+ SELECT 1 FROM relay_boards newer
215
+ WHERE newer.conversation_id = b.conversation_id AND newer.status = 'pending' AND newer.board_type = 'plan_ready'
216
+ AND (newer.created_at > b.created_at OR (newer.created_at = b.created_at AND newer.rowid > b.rowid))
217
+ )
218
+ ORDER BY b.created_at DESC LIMIT ?
219
+ `) || null;
220
+ const settledPlanBoardsStmt = db?.prepare?.(`
221
+ SELECT b.* FROM relay_boards b JOIN conversations c ON c.id = b.conversation_id
222
+ WHERE b.status IN ('acted', 'dismissed') AND b.board_type = 'plan_ready' AND b.acted_at >= ? AND ${planBoardSessionFilter}
223
+ ORDER BY b.acted_at DESC LIMIT ?
224
+ `) || null;
225
+
157
226
  function sessionOf(id) {
158
227
  let row = null;
159
228
  try {
@@ -204,13 +273,20 @@ export function registerRelayAskRoutes(app, deps) {
204
273
  return { relayId, name: toText(paired?.name) || claimed || toText(request.origin?.relayName) || relayId };
205
274
  }
206
275
 
207
- /** What an Inbox shows of this relay: its asks, and the cards turns wait on. */
208
- function localInboxItems({ forRemote = false } = {}) {
276
+ /**
277
+ * What an Inbox shows of this relay: its asks, and the cards turns wait on.
278
+ * `read`, when given, learns whether the pending cards could be read
279
+ * (`read.pendingCards`): a failed read lists none, which is not "none pending".
280
+ */
281
+ function localInboxItems({ forRemote = false, read = null } = {}) {
209
282
  const items = (relayAskService?.isAvailable?.() ? relayAskService.listHere() : []).map(inboxItemOfAsk);
210
283
  let pending = [];
211
284
  try {
212
285
  pending = stmts.listQuestions.all('pending', null, null).map(formatQuestionRow).filter(Boolean);
213
- } catch {}
286
+ if (read) read.pendingCards = true;
287
+ } catch {
288
+ if (read) read.pendingCards = false;
289
+ }
214
290
  let answered = [];
215
291
  try {
216
292
  const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
@@ -219,6 +295,19 @@ export function registerRelayAskRoutes(app, deps) {
219
295
  for (const question of [...pending, ...answered]) {
220
296
  items.push(inboxItemOfCard(question, sessionOf(question.conversationId), { forRemote }));
221
297
  }
298
+ if (typeof formatRelayBoardRow === 'function') {
299
+ let boards = [];
300
+ try {
301
+ const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
302
+ boards = [
303
+ ...(pendingPlanBoardsStmt?.all(INBOX_PLAN_CARDS_MAX) || []),
304
+ ...(settledPlanBoardsStmt?.all(since, INBOX_PLAN_CARDS_MAX) || []),
305
+ ].map(formatRelayBoardRow).filter(Boolean);
306
+ } catch {}
307
+ for (const board of boards) {
308
+ items.push(inboxItemOfPlanBoard(board, sessionOf(board.conversationId), { forRemote }));
309
+ }
310
+ }
222
311
  return items.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
223
312
  }
224
313
 
@@ -375,11 +464,52 @@ export function registerRelayAskRoutes(app, deps) {
375
464
  }
376
465
  });
377
466
 
378
- // GET /api/relay-asks/remote/inbox — what a paired relay's Inbox shows of this one.
467
+ // Who pushes a card a turn waits on: question id → the paired
468
+ // relay (its id) that claimed it. In memory: after a restart a
469
+ // card is just claimable again.
470
+ const claims = new Map();
471
+
472
+ /**
473
+ * `notice` (and with `you`, `claimed`) for each pending approval and
474
+ * ask_user item, for a caller that can push: this relay's own push
475
+ * decides first, then the first caller to find it missed claims it.
476
+ */
477
+ function addNotices(items, callerRelayId) {
478
+ const pendingIds = new Set();
479
+ const noticed = items.map((item) => {
480
+ if (item.status !== 'pending' || (item.kind !== 'approval' && item.kind !== 'ask_user')) return item;
481
+ pendingIds.add(item.id);
482
+ let own = null;
483
+ try {
484
+ own = typeof questionNotice === 'function' ? questionNotice(item.id) : null;
485
+ } catch {}
486
+ if (own === 'reached') return { ...item, notice: REMOTE_INBOX_NOTICES.here };
487
+ if (own === 'pending') return { ...item, notice: REMOTE_INBOX_NOTICES.wait };
488
+ const claim = claims.get(item.id);
489
+ if (!claim) {
490
+ claims.set(item.id, callerRelayId);
491
+ return { ...item, notice: REMOTE_INBOX_NOTICES.you, claimed: REMOTE_INBOX_CLAIMS.now };
492
+ }
493
+ if (claim === callerRelayId) return { ...item, notice: REMOTE_INBOX_NOTICES.you, claimed: REMOTE_INBOX_CLAIMS.before };
494
+ return { ...item, notice: REMOTE_INBOX_NOTICES.other };
495
+ });
496
+ // A card no longer pending needs no claim.
497
+ for (const id of claims.keys()) {
498
+ if (!pendingIds.has(id)) claims.delete(id);
499
+ }
500
+ return noticed;
501
+ }
502
+
503
+ // GET /api/relay-asks/remote/inbox — what a paired relay's Inbox shows of
504
+ // this one. `?push=1`: the caller can push, and learns who pushes each card.
379
505
  app.get('/api/relay-asks/remote/inbox', auth, (req, res) => {
380
506
  const caller = admitPairedRelay(req, res);
381
507
  if (!caller) return;
382
- const items = localInboxItems({ forRemote: true });
508
+ const read = {};
509
+ let items = localInboxItems({ forRemote: true, read });
510
+ // Pending cards that could not be read are not "no card pending": the
511
+ // claims stay, and no card is claimed or offered on this read.
512
+ if (toText(req.query?.push) === '1' && read.pendingCards) items = addNotices(items, caller.relayId);
383
513
  res.json({ ok: true, items, pending: pendingOf(items) });
384
514
  });
385
515
 
@@ -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({