@oar-sh/oar 0.9.19 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -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';
@@ -44,6 +45,7 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
44
45
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
45
46
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
46
47
  import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
48
+ import { normalizeBuildId } from '../../shared/build-id.mjs';
47
49
  import {
48
50
  DEFAULT_TASK_STALL_MINUTES,
49
51
  TASK_OUTPUT_LIMITS,
@@ -86,8 +88,11 @@ import {
86
88
  import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
87
89
  import { recordClaudeCliStart } from '../services/claude-session-leftovers.mjs';
88
90
  import { normalizeClaudeBilling } from '../services/claude-billing-switch-service.mjs';
91
+ import { normalizeTurnTokenUsageReport } from '../../shared/turn-token-usage.mjs';
89
92
  import { createClaudeApiStopNoteLedger } from '../services/plan-usage-claude-api.mjs';
90
93
  import { normalizeClaudeApiUsageReport } from '../services/claude-api-usage-service.mjs';
94
+ import { planBoundaryHoldSql } from '../repositories/message-repository.mjs';
95
+ import { PLAN_ONLY_REFUSAL, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
91
96
  import {
92
97
  admitRemoteRelayRequest,
93
98
  readRemoteRelayRequest,
@@ -921,6 +926,23 @@ export function resolveResponderConversationMismatch({
921
926
  };
922
927
  }
923
928
 
929
+ /**
930
+ * Whether a pending row must wait for its conversation's live turn to end
931
+ * instead of being handed to the worker, which would steer it into that turn.
932
+ * A steer switches the live turn's mode (the Claude worker re-applies the
933
+ * permission mode on every delivery), so across plan mode it would change
934
+ * what the running turn may do: an "Approve plan" follow-up would give a
935
+ * running plan turn write access, and a plan message would take it from a
936
+ * running write turn. Rows on the same side of plan mode steer as before.
937
+ */
938
+ export function crossesPlanBoundaryOfLiveTurn({ stmts, row } = {}) {
939
+ const conversationId = String(row?.conversation_id || '').trim();
940
+ if (!conversationId || typeof stmts?.listProcessingQueueByConversation?.all !== 'function') return false;
941
+ const isPlan = (mode) => String(mode || '').trim().toLowerCase() === 'plan';
942
+ const pendingIsPlan = isPlan(row.relay_mode);
943
+ return stmts.listProcessingQueueByConversation.all(conversationId)
944
+ .some((live) => String(live?.id || '') !== String(row.id || '') && isPlan(live?.relay_mode) !== pendingIsPlan);
945
+ }
924
946
  export function dequeuePendingMessage({
925
947
  db,
926
948
  stmts,
@@ -970,6 +992,10 @@ export function dequeuePendingMessage({
970
992
  : stmts.findPending.get(currentIso);
971
993
  }
972
994
  if (!next) return null;
995
+ // Across plan mode, a row waits for the live turn instead of steering in.
996
+ // The claim statements leave such rows out (planBoundaryHoldSql); this is
997
+ // the fail-closed backstop for one that does not.
998
+ if (crossesPlanBoundaryOfLiveTurn({ stmts, row: next })) return null;
973
999
  // One processing attempt = one attempt_id. Minted here for every claim —
974
1000
  // even for callers that never echo it back — so finalization, requeue, and
975
1001
  // question writes can be fenced to the attempt that actually owns the row.
@@ -1176,6 +1202,12 @@ export async function dequeuePendingMessageForWorkerLoop({
1176
1202
  workerKind = null,
1177
1203
  onBillingFence = null,
1178
1204
  claudeApiClaimCheck = null,
1205
+ // () → true while the replacement fence (services/worker-replacement.mjs)
1206
+ // is up for the requesting process. The caller asks it before calling; it
1207
+ // is asked again before every claim, since ensureWorker and a transient
1208
+ // error's backoff both wait, and the worker recycle can raise the fence
1209
+ // meanwhile.
1210
+ isReplacementFenced = null,
1179
1211
  } = {}) {
1180
1212
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
1181
1213
  const routingWithWorker = routingEnabled && requesterSid;
@@ -1250,6 +1282,18 @@ export async function dequeuePendingMessageForWorkerLoop({
1250
1282
 
1251
1283
  let attempt = 0;
1252
1284
  while (attempt <= retryLimit) {
1285
+ // The process asking is being replaced: no row is claimed for it, the
1286
+ // same answer as the caller's own fence check gives.
1287
+ if (routingWithWorker && typeof isReplacementFenced === 'function' && isReplacementFenced() === true) {
1288
+ return {
1289
+ message: null,
1290
+ blockedReason: 'worker-replacement',
1291
+ worker: supervisor?.getWorkerState?.(requesterSid) || null,
1292
+ lifecycle: supervisor?.getLifecycleState?.(requesterSid) || null,
1293
+ fallbackRestart: null,
1294
+ attempts: attempt,
1295
+ };
1296
+ }
1253
1297
  const currentIso = String(nowIso || '').trim() || new Date().toISOString();
1254
1298
  try {
1255
1299
  const message = dequeuePendingMessage({
@@ -2040,6 +2084,9 @@ export function registerMessagesRoutes(app, deps) {
2040
2084
  // killTmuxSessionImpl). Tests must inject these instead of relying on the
2041
2085
  // host OS, so the suite behaves identically on Windows and POSIX.
2042
2086
  sessionWorkerStopOverrides = null,
2087
+ // The replacement fence (services/worker-replacement.mjs): a worker
2088
+ // process the recycle is replacing is handed no work by /api/pending.
2089
+ workerReplacementFence = null,
2043
2090
  resolveSessionStateRoot,
2044
2091
  fetchUsageSummary = null,
2045
2092
  planUsageService = null,
@@ -2049,6 +2096,8 @@ export function registerMessagesRoutes(app, deps) {
2049
2096
  claudeBillingSwitchService = null,
2050
2097
  // The Claude API booking (claude-api-usage-service.mjs).
2051
2098
  claudeApiUsageService = null,
2099
+ // Tokens per turn for every provider (token-usage-service.mjs).
2100
+ tokenUsageService = null,
2052
2101
  // The delivery fence's runtime half for GET /api/pending, as on the
2053
2102
  // socket: the Claude API claim check (null: no refusal) and what a fenced
2054
2103
  // row does (the swap, or fail closed).
@@ -2640,6 +2689,7 @@ export function registerMessagesRoutes(app, deps) {
2640
2689
  AND rs.sdk_session_id = ?
2641
2690
  )
2642
2691
  AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
2692
+ AND ${planBoundaryHoldSql('q')}
2643
2693
  ORDER BY q.timestamp ASC, q.rowid ASC
2644
2694
  LIMIT 1
2645
2695
  `);
@@ -4804,6 +4854,12 @@ export function registerMessagesRoutes(app, deps) {
4804
4854
  ? normalizeRelayMode(existingConversation.preferred_relay_mode)
4805
4855
  : null;
4806
4856
  const requestedRelayMode = savedRelayMode || normalizeRelayMode(relayMode || mode);
4857
+ // Plan-only: an agent's send that names another mode is refused
4858
+ // and nothing is queued (nor a billing switched). Without a mode it runs
4859
+ // in the session's own. The owner's own sends are not limited.
4860
+ if (remoteRequest.remote && readPlanOnly(existingConversation) && planOnlyRefusesMode(relayMode || mode)) {
4861
+ return res.status(409).json({ ...PLAN_ONLY_REFUSAL, conversationId });
4862
+ }
4807
4863
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
4808
4864
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4809
4865
  const requestedAttachmentCount = Math.min(
@@ -6211,7 +6267,10 @@ export function registerMessagesRoutes(app, deps) {
6211
6267
  // Whether a billing swap may replace the worker now, and why not.
6212
6268
  || (stored.releasable ?? null) !== (steering.releasable ?? null)
6213
6269
  || (stored.releaseHold || null) !== (steering.releaseHold || null)
6214
- || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0);
6270
+ || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0)
6271
+ // Whether the worker's CLI child still runs: the worker recycle
6272
+ // waits for it to be gone, so a flip must be stored.
6273
+ || (stored.cliAlive ?? null) !== (steering.cliAlive ?? null);
6215
6274
  if (workerForSteering && changed) {
6216
6275
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
6217
6276
  // Pushed rather than left for the clients' status poll: the
@@ -6229,6 +6288,16 @@ export function registerMessagesRoutes(app, deps) {
6229
6288
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
6230
6289
  }
6231
6290
  }
6291
+ // The build id of the code the worker started on, recorded the same way:
6292
+ // only when it says one and the registry has another, so quiet
6293
+ // heartbeats stay write-free.
6294
+ const heartbeatBuildId = normalizeBuildId(req.body?.buildId);
6295
+ if (heartbeatBuildId) {
6296
+ const workerForBuild = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6297
+ if (workerForBuild && workerForBuild.buildId !== heartbeatBuildId) {
6298
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForBuild, buildId: heartbeatBuildId });
6299
+ }
6300
+ }
6232
6301
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
6233
6302
  // The worker's digest of its live background-task ids against the set
6234
6303
  // held for it (keyed as the worker posts, by its session id). They
@@ -6611,6 +6680,59 @@ export function registerMessagesRoutes(app, deps) {
6611
6680
  res.json({ ok: true, billing: booked.billing, basis: booked.basis, models: booked.booked.map((entry) => entry.model) });
6612
6681
  });
6613
6682
 
6683
+ // POST /api/turn-token-usage — every worker reports, after a turn, the
6684
+ // turn's tokens per model (`models`), or a Claude cloud worker the
6685
+ // session's running totals (`sessionTotals`), keyed by turn, run and model
6686
+ // (shared/turn-token-usage.mjs). Advisory and idempotent: a retried or
6687
+ // reordered report books only what it adds (token-usage-service.mjs).
6688
+ // Who ran the turn is the relay's to say: the chat's provider, and for a
6689
+ // Claude chat the posting worker's registered kind (the billing can switch
6690
+ // between turns). A body that says otherwise is logged. A deleted chat
6691
+ // takes no report, so a late retry cannot book its turn a second time; an
6692
+ // archived one does.
6693
+ app.post('/api/turn-token-usage', auth, (req, res) => {
6694
+ touchCli();
6695
+ if (!tokenUsageService) return res.status(501).json({ error: 'Token usage storage is unavailable' });
6696
+ const parsed = normalizeTurnTokenUsageReport(req.body);
6697
+ if (!parsed.ok) return res.status(400).json({ error: parsed.error });
6698
+ const { report } = parsed;
6699
+ const conversationId = report.conversationId;
6700
+ if (!stmts.getConv?.get?.(conversationId)) return res.status(404).json({ error: 'Conversation not found' });
6701
+ // The binding's provider wins over the report's. A chat whose binding is
6702
+ // gone for a moment (a cache rebuild) still books, as the provider the
6703
+ // report states.
6704
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
6705
+ const chatProvider = String(runtimeSession?.provider_type || report.provider || '').trim().toLowerCase();
6706
+ let provider = chatProvider;
6707
+ let billing = '';
6708
+ if (chatProvider === 'openai') {
6709
+ // A chat on the user's own OpenAI key runs on the Copilot SDK.
6710
+ provider = 'github';
6711
+ billing = 'byok-openai';
6712
+ } else if (chatProvider === 'github') {
6713
+ billing = report.billing;
6714
+ } else if (normalizeClaudeBilling(chatProvider)) {
6715
+ const requesterSid = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
6716
+ const registeredKind = normalizeClaudeBilling(requesterSid ? sessionWorkerRegistry?.getWorker?.(requesterSid)?.kind : '');
6717
+ // The worker that ran the turn, then the chat's billing; the report's
6718
+ // own word only when the chat has no binding.
6719
+ provider = registeredKind || normalizeClaudeBilling(chatProvider);
6720
+ } else if (!['claude-cloud', 'deepseek', 'cursor', 'grok'].includes(chatProvider)) {
6721
+ return res.status(409).json({ error: 'Conversation keeps no token usage' });
6722
+ }
6723
+ if (report.provider && report.provider !== provider) {
6724
+ console.warn(`[${ts()}] token usage conv=${conversationId.slice(0, 8)} stated=${report.provider} booked=${provider}`);
6725
+ }
6726
+ if (report.sessionTotals && provider !== 'claude-cloud') {
6727
+ return res.status(400).json({ error: 'sessionTotals is for Claude cloud only' });
6728
+ }
6729
+ const booked = report.sessionTotals
6730
+ ? tokenUsageService.recordCloudSessionTotals({ conversationId, provider, billing, report })
6731
+ : tokenUsageService.recordTurn({ conversationId, provider, billing, report });
6732
+ if (!booked) return res.status(501).json({ error: 'Token usage storage is unavailable' });
6733
+ res.json({ ok: true, provider, billing, models: booked.map((entry) => entry.model) });
6734
+ });
6735
+
6614
6736
  // POST /api/claude-plan-usage — Claude worker reports the session's
6615
6737
  // structured /usage data (plan rate-limit windows + session cost totals).
6616
6738
  //
@@ -6922,6 +7044,24 @@ export function registerMessagesRoutes(app, deps) {
6922
7044
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
6923
7045
  });
6924
7046
  }
7047
+ // The process asking is being replaced (the worker recycle): no work,
7048
+ // as for a killed session, so no turn starts on the pid being stopped.
7049
+ // Unlike the killed marker, the fence outlives the marker's grace and
7050
+ // every clearRestartSchedule; the new worker has another pid and takes
7051
+ // what waited meanwhile.
7052
+ if (workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true) {
7053
+ return res.json({
7054
+ message: null,
7055
+ routing: {
7056
+ enabled: true,
7057
+ requesterSessionId,
7058
+ blockedReason: 'worker-replacement',
7059
+ lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
7060
+ fallbackRestart: null,
7061
+ },
7062
+ restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
7063
+ });
7064
+ }
6925
7065
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
6926
7066
  }
6927
7067
  if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
@@ -7016,6 +7156,8 @@ export function registerMessagesRoutes(app, deps) {
7016
7156
  workerKind: httpPendingWorkerKind(requesterSessionId),
7017
7157
  claudeApiClaimCheck,
7018
7158
  onBillingFence: onClaudeBillingFence,
7159
+ // The fence check above, asked again before every claim.
7160
+ isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
7019
7161
  telemetry: (payload = {}) => {
7020
7162
  if (!sessionWorkerRoutingEnabled) return;
7021
7163
  emitSessionWorkerTelemetry(payload.event, {
@@ -7236,6 +7378,7 @@ export function registerMessagesRoutes(app, deps) {
7236
7378
  workerKind: httpPendingWorkerKind(requesterSessionId),
7237
7379
  claudeApiClaimCheck,
7238
7380
  onBillingFence: onClaudeBillingFence,
7381
+ isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
7239
7382
  });
7240
7383
  if (recoveryDequeue?.message) {
7241
7384
  msg = recoveryDequeue.message;
@@ -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({