@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
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import {
3
4
  CLAUDE_LIVE_FLAG_SETTINGS_CODE,
4
5
  CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
@@ -6,17 +7,12 @@ import {
6
7
  claudeFailureGuidance,
7
8
  } from './claude-turn-failure.mjs';
8
9
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
10
+ import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
9
11
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
10
12
  import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
11
13
  import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
12
14
  import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
13
- import { claudeBillingTotals, claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
14
-
15
- const PLAN_BOARD_ACTIONS = [
16
- { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
17
- { id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
18
- { id: 'exit_only', label: 'Stop here', mode: 'agent' },
19
- ];
15
+ import { claudeBillingTotals, claudeSearchTotals, claudeTokenTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
20
16
 
21
17
  /**
22
18
  * The attempt-fencing echo for a row's write bodies. Every publish that names
@@ -96,6 +92,13 @@ export function createClaudeTurnPublisher({
96
92
  // `billingKind` is the worker's kind.
97
93
  billingUsageRoute = null,
98
94
  billingKind = null,
95
+ // The token booking (POST /api/turn-token-usage): the provider a
96
+ // turn's tokens are reported under (the worker's kind: 'claude',
97
+ // 'claude-api', 'deepseek'), null for none. `turnTokenCost(entry, at)`
98
+ // prices a model's share of a turn where Claude Code's own estimate does
99
+ // not fit (DeepSeek: `{ costUsd, costSource }`, costUsd null when unknown).
100
+ turnTokenProvider = null,
101
+ turnTokenCost = null,
99
102
  } = {}) {
100
103
  /** `{ code, stableCode }` of a failure code, in the backend's namespace. */
101
104
  function failureCodes(code) {
@@ -230,6 +233,9 @@ export function createClaudeTurnPublisher({
230
233
  * Advisory: a failure here must not disturb the turn's response.
231
234
  */
232
235
  async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
236
+ // Fire-and-forget: the token post never holds the reply (the counting
237
+ // itself runs at once, before the next result can arrive).
238
+ if (turnTokenProvider) void publishTurnTokens({ message, state, runId, resumedSessionId });
233
239
  if (billingUsageRoute) await publishBillingUsage({ message, state, sdkSessionId, runId, resumedSessionId });
234
240
  if (usageRoute) {
235
241
  await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
@@ -252,6 +258,20 @@ export function createClaudeTurnPublisher({
252
258
  });
253
259
  }
254
260
 
261
+ // Three trackers below measure a turn from the CLI's results, one per
262
+ // booking, and mirror each other's rules: `run` / runningCounts (DeepSeek's
263
+ // card), `billingRun` / billingCounts (the Claude API booking) and
264
+ // `tokenRun` / turnCounts (the token booking). Each keys on the
265
+ // process (`runId`) and the CLI session id, so a `/clear` starts over;
266
+ // takes a fresh process's `modelUsage` growth over the last result's, but
267
+ // a resumed process's first result (and a result without `modelUsage`) as
268
+ // its turn `usage` alone, the main loop, and a result without `modelUsage`
269
+ // raises the baseline by that `usage`, so the next result's growth does
270
+ // not count it again; lets the baseline only rise, so a late result of an
271
+ // older turn books nothing twice; and counts a turn state once. They differ only in what they keep: hits and misses (cache
272
+ // writes inside) for the first two, the four token kinds apart for the
273
+ // third. A change to one rule belongs in all three.
274
+
255
275
  // The CLI process's own token totals and web search and fetch counts per
256
276
  // model, counted from zero for each process (`runId`) and CLI session id
257
277
  // (a `/clear` changes it and starts the CLI's totals over). `lastSession`
@@ -475,7 +495,10 @@ export function createClaudeTurnPublisher({
475
495
  }));
476
496
  }
477
497
  // The baseline only rises: a late result of an older turn below it
478
- // must not make the next one count twice.
498
+ // must not make the next one count twice. A result without
499
+ // `modelUsage` grew the CLI's running totals by its `usage`, as `run`
500
+ // has it: the baseline grows by it too, so the next result's growth
501
+ // does not book that turn again.
479
502
  if (hasModelUsage) {
480
503
  for (const entry of session) {
481
504
  const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
@@ -486,6 +509,16 @@ export function createClaudeTurnPublisher({
486
509
  costUsd: Math.max(last.costUsd, entry.costUsd),
487
510
  });
488
511
  }
512
+ } else {
513
+ for (const entry of claudeBillingTotals(null, { usage: result.usage, model })) {
514
+ const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
515
+ billingRun.lastSession.set(entry.model, {
516
+ hitTokens: last.hitTokens + entry.hitTokens,
517
+ missTokens: last.missTokens + entry.missTokens,
518
+ outputTokens: last.outputTokens + entry.outputTokens,
519
+ costUsd: last.costUsd,
520
+ });
521
+ }
489
522
  }
490
523
  billingRun.results += 1;
491
524
  }
@@ -496,6 +529,128 @@ export function createClaudeTurnPublisher({
496
529
  };
497
530
  }
498
531
 
532
+ // The token booking's counts, measured like billingRun above,
533
+ // with the four token kinds apart, then summed per turn: a turn's results
534
+ // (a stopped turn's, the steered rows it folded in) post the turn's total
535
+ // so far under its id, which the relay keeps the larger of. A worker
536
+ // without a process id names one for its own life, so a requeued turn on
537
+ // a restarted worker is a second run, not the same row.
538
+ const tokenRun = { key: '', results: 0, lastSession: new Map() };
539
+ const tokenStates = new WeakSet();
540
+ const turnTokens = new Map();
541
+ const ownRunId = randomUUID();
542
+ const TOKEN_FIELDS = ['inputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'outputTokens'];
543
+ const TURN_TOKENS_KEPT = 20;
544
+
545
+ function turnCounts(state, result, { turnId, runId, sessionId, resumedSessionId, modelKey }) {
546
+ const model = result.model || state.responseModel;
547
+ const key = `${runId}\n${sessionId}`;
548
+ if (tokenRun.key !== key) {
549
+ tokenRun.key = key;
550
+ tokenRun.results = 0;
551
+ tokenRun.lastSession = new Map();
552
+ }
553
+ // Per run and turn: a turn re-run by a new CLI process (after a crash)
554
+ // is a second row at the relay, holding that run's tokens only.
555
+ const turnKey = `${runId}\n${turnId}`;
556
+ let turn = turnTokens.get(turnKey);
557
+ if (!turn) {
558
+ turn = new Map();
559
+ turnTokens.set(turnKey, turn);
560
+ while (turnTokens.size > TURN_TOKENS_KEPT) turnTokens.delete(turnTokens.keys().next().value);
561
+ }
562
+ if (!tokenStates.has(state)) {
563
+ tokenStates.add(state);
564
+ const at = new Date().toISOString();
565
+ const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
566
+ const resumed = tokenRun.results === 0 && Boolean(resumedSessionId) && sessionId === resumedSessionId;
567
+ const session = hasModelUsage ? claudeTokenTotals(result.modelUsage, modelKey) : [];
568
+ const grown = !hasModelUsage || resumed
569
+ ? claudeTokenTotals(null, { usage: result.usage, model, ...modelKey })
570
+ : session.map((entry) => {
571
+ const last = tokenRun.lastSession.get(entry.model);
572
+ const delta = { model: entry.model, costUnknown: entry.costUnknown };
573
+ for (const field of TOKEN_FIELDS) delta[field] = Math.max(0, entry[field] - (last?.[field] || 0));
574
+ delta.costUsd = entry.costUsd === null ? null : Math.max(0, entry.costUsd - (last?.costUsd || 0));
575
+ return delta;
576
+ });
577
+ for (const entry of grown) {
578
+ const priced = typeof turnTokenCost === 'function' ? turnTokenCost(entry, at) : null;
579
+ const costUsd = priced ? priced.costUsd : entry.costUsd;
580
+ const costSource = priced ? priced.costSource : (entry.costUsd === null ? null : 'claude-code');
581
+ const current = turn.get(entry.model) || {
582
+ model: entry.model, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUsd: null, costSource: null, costUnknown: false,
583
+ };
584
+ for (const field of TOKEN_FIELDS) current[field] += entry[field];
585
+ if (costUsd !== null && costUsd !== undefined) current.costUsd = (current.costUsd ?? 0) + costUsd;
586
+ current.costSource = current.costSource || costSource;
587
+ // A backend's own price wins over Claude Code's word on it (DeepSeek's
588
+ // models are unknown to Claude Code's table, yet OAR prices them).
589
+ const unknown = priced ? priced.costUsd === null || priced.costUsd === undefined : (entry.costUnknown || costUsd === null || costUsd === undefined);
590
+ current.costUnknown = current.costUnknown || unknown;
591
+ turn.set(entry.model, current);
592
+ }
593
+ // The baseline only rises: a late result of an older turn below it
594
+ // must not make the next one count twice. A result without
595
+ // `modelUsage` grew the CLI's running totals by its `usage`, as `run`
596
+ // has it: the baseline grows by it too.
597
+ for (const entry of session) {
598
+ const last = tokenRun.lastSession.get(entry.model) || {};
599
+ const next = { costUsd: Math.max(last.costUsd || 0, entry.costUsd || 0) };
600
+ for (const field of TOKEN_FIELDS) next[field] = Math.max(last[field] || 0, entry[field]);
601
+ tokenRun.lastSession.set(entry.model, next);
602
+ }
603
+ if (!hasModelUsage) {
604
+ for (const entry of grown) {
605
+ const last = tokenRun.lastSession.get(entry.model) || {};
606
+ const next = { costUsd: last.costUsd || 0 };
607
+ for (const field of TOKEN_FIELDS) next[field] = (last[field] || 0) + entry[field];
608
+ tokenRun.lastSession.set(entry.model, next);
609
+ }
610
+ }
611
+ tokenRun.results += 1;
612
+ }
613
+ return [...turn.values()]
614
+ .filter((entry) => TOKEN_FIELDS.some((field) => entry[field] > 0) || entry.costUsd > 0)
615
+ .map((entry) => ({ ...entry }));
616
+ }
617
+
618
+ /**
619
+ * Ship the turn's tokens per model to the token booking: the
620
+ * turn's total so far, under the turn's id and the process's run id.
621
+ * Reasoning is not reported apart by Claude, so it is left out (NULL).
622
+ * Advisory and fire-and-forget: it never throws and nothing waits on it.
623
+ */
624
+ async function publishTurnTokens({ message, state, runId = '', resumedSessionId = '' }) {
625
+ try {
626
+ const result = state.result;
627
+ const turnId = String(message?.id || '').trim();
628
+ if (!turnId || !result) return;
629
+ const sessionId = String(result.sessionId || '').trim();
630
+ const modelKey = typeof usageModelKey === 'function' ? { modelKey: usageModelKey } : {};
631
+ const run = String(runId || '') || ownRunId;
632
+ const models = turnCounts(state, result, {
633
+ turnId,
634
+ runId: run,
635
+ sessionId,
636
+ resumedSessionId: String(resumedSessionId || '').trim(),
637
+ modelKey,
638
+ });
639
+ if (!models.length) return;
640
+ await api('POST', '/api/turn-token-usage', {
641
+ conversationId: message.conversationId,
642
+ turnId,
643
+ runId: run,
644
+ provider: turnTokenProvider,
645
+ ...(sessionId ? { nativeSessionId: sessionId } : {}),
646
+ at: new Date().toISOString(),
647
+ models,
648
+ });
649
+ } catch (error) {
650
+ dbg('turn token publish failed', error?.message || String(error));
651
+ }
652
+ }
653
+
499
654
  /**
500
655
  * Ship the turn's counts to the Claude API booking: the process's own
501
656
  * running totals (`runCounts`) and the CLI's session totals
@@ -159,3 +159,61 @@ export function claudeBillingTotals(modelUsage, { usage = null, model = '' } = {
159
159
  }
160
160
  return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0 || entry.costUsd > 0);
161
161
  }
162
+
163
+ /**
164
+ * The CLI's totals per model with the four token kinds apart, for the token
165
+ * booking (POST /api/turn-token-usage): `[{ model, inputTokens,
166
+ * cacheWriteTokens, cacheReadTokens, outputTokens, costUsd, costUnknown }]`.
167
+ * `inputTokens` is fresh input (Anthropic counts cache reads and writes
168
+ * apart). Read like claudeBillingTotals: `modelUsage` (running totals, with
169
+ * Claude Code's cost estimate) or `usage` alone under the turn's model (no
170
+ * cost: `costUsd` null). `modelKey` maps the CLI's model names to the
171
+ * relay's, as in claudeUsageTotals; the `[1m]` tier is dropped. Models with
172
+ * no tokens and no cost are left out.
173
+ */
174
+ export function claudeTokenTotals(modelUsage, {
175
+ usage = null,
176
+ model = '',
177
+ modelKey = (value) => String(value ?? '').trim(),
178
+ } = {}) {
179
+ const totals = new Map();
180
+ const add = (name, entry) => {
181
+ const key = String(modelKey(name) ?? '').trim().toLowerCase().replace(/\[1m\]$/, '');
182
+ if (!key) return;
183
+ const current = totals.get(key) || {
184
+ model: key, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUsd: null, costUnknown: false,
185
+ };
186
+ current.inputTokens += entry.inputTokens;
187
+ current.cacheWriteTokens += entry.cacheWriteTokens;
188
+ current.cacheReadTokens += entry.cacheReadTokens;
189
+ current.outputTokens += entry.outputTokens;
190
+ if (entry.costUsd !== null) current.costUsd = (current.costUsd ?? 0) + entry.costUsd;
191
+ current.costUnknown = current.costUnknown || entry.costUnknown;
192
+ totals.set(key, current);
193
+ };
194
+ if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
195
+ for (const [name, entry] of Object.entries(modelUsage)) {
196
+ if (!entry || typeof entry !== 'object') continue;
197
+ add(name, {
198
+ inputTokens: count(entry.inputTokens),
199
+ cacheWriteTokens: count(entry.cacheCreationInputTokens),
200
+ cacheReadTokens: count(entry.cacheReadInputTokens),
201
+ outputTokens: count(entry.outputTokens),
202
+ costUsd: costOf(entry.costUSD),
203
+ costUnknown: entry.costBasis === 'unknown',
204
+ });
205
+ }
206
+ } else if (usage && typeof usage === 'object') {
207
+ add(model, {
208
+ inputTokens: count(usage.input_tokens),
209
+ cacheWriteTokens: count(usage.cache_creation_input_tokens),
210
+ cacheReadTokens: count(usage.cache_read_input_tokens),
211
+ outputTokens: count(usage.output_tokens),
212
+ costUsd: null,
213
+ costUnknown: false,
214
+ });
215
+ }
216
+ return [...totals.values()].filter((entry) => (
217
+ entry.inputTokens + entry.cacheWriteTokens + entry.cacheReadTokens + entry.outputTokens > 0 || entry.costUsd > 0
218
+ ));
219
+ }
@@ -14,16 +14,13 @@
14
14
  // shape of the final prose. Both paths are wired — the hook is primary, the
15
15
  // text heuristic is the fallback — which is the Claude worker's arrangement.
16
16
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
17
+ import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
17
18
 
18
19
  /**
19
- * Kept identical across workers. `exit_only` dismisses; `autopilot` and
20
- * `interactive` queue a follow-up user message in the mapped relay mode.
20
+ * The one list every worker posts (shared/plan-board-actions.mjs): `exit_only`
21
+ * dismisses; the others queue a follow-up user message in the mapped mode.
21
22
  */
22
- export const PLAN_BOARD_ACTIONS = [
23
- { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
24
- { id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
25
- { id: 'exit_only', label: 'Stop here', mode: 'agent' },
26
- ];
23
+ export { PLAN_BOARD_ACTIONS };
27
24
 
28
25
  /**
29
26
  * How many list-ish lines a final message needs before it is treated as a plan.
@@ -174,7 +174,7 @@ export function formatSubagentStats(data) {
174
174
  * figures keep the FIRST call's time-to-first-token (that is the number a user
175
175
  * experiences as "did it start answering").
176
176
  */
177
- function createUsageAccumulator() {
177
+ function createUsageAccumulator({ debugTokenUsage = null } = {}) {
178
178
  const totals = {
179
179
  inputTokens: 0,
180
180
  outputTokens: 0,
@@ -195,8 +195,61 @@ function createUsageAccumulator() {
195
195
  isByok: null,
196
196
  quotaSnapshots: null,
197
197
  };
198
+ // The token booking's view (POST /api/turn-token-usage): the same calls,
199
+ // per model, the four token kinds apart. A field no call of the model
200
+ // carried stays null, so "not reported" is not booked as 0.
201
+ const byModel = new Map();
202
+ let compactionCalls = 0;
203
+ // A compaction's call as `session.compaction_complete` reports it, kept
204
+ // apart until the snapshot: it is booked only if no usage event of the
205
+ // compaction class came in the turn, before or after it.
206
+ const compactionTokens = new Map();
207
+ function addTokens(model, data, into = byModel) {
208
+ const key = String(model || '').trim();
209
+ const bucket = into.get(key) || {
210
+ model: key, inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, reasoningTokens: null,
211
+ };
212
+ const count = (value) => {
213
+ if (value === null || value === undefined || value === '') return null;
214
+ const n = Number(value);
215
+ return Number.isFinite(n) && n >= 0 ? n : null;
216
+ };
217
+ const cacheRead = count(data.cacheReadTokens);
218
+ const cacheWrite = count(data.cacheWriteTokens);
219
+ const prompt = count(data.inputTokens);
220
+ // Fresh input is the prompt count less the cache reads and writes, per
221
+ // call, floored at 0. Inferred, not verified: the bundled runtime reads
222
+ // OpenAI-style `prompt_tokens` with `prompt_tokens_details.cached_tokens`
223
+ // (and cache creation), where the prompt count includes the cache. A
224
+ // real-provider check at the merge gate confirms it, reading the raw
225
+ // counts that OAR_DEBUG_TOKEN_USAGE=1 logs.
226
+ const fresh = prompt === null ? null : Math.max(0, prompt - (cacheRead || 0) - (cacheWrite || 0));
227
+ for (const [field, value] of [
228
+ ['inputTokens', fresh],
229
+ ['outputTokens', count(data.outputTokens)],
230
+ ['cacheReadTokens', cacheRead],
231
+ ['cacheWriteTokens', cacheWrite],
232
+ ['reasoningTokens', count(data.reasoningTokens)],
233
+ ]) {
234
+ if (value !== null) bucket[field] = (bucket[field] ?? 0) + value;
235
+ }
236
+ into.set(key, bucket);
237
+ }
198
238
  function add(data, { allowModel = true } = {}) {
199
239
  if (!data || typeof data !== 'object') return;
240
+ if (typeof debugTokenUsage === 'function') {
241
+ debugTokenUsage({
242
+ model: data.model ?? null,
243
+ interactionType: data.interactionType ?? null,
244
+ inputTokens: data.inputTokens ?? null,
245
+ cacheReadTokens: data.cacheReadTokens ?? null,
246
+ cacheWriteTokens: data.cacheWriteTokens ?? null,
247
+ outputTokens: data.outputTokens ?? null,
248
+ reasoningTokens: data.reasoningTokens ?? null,
249
+ });
250
+ }
251
+ addTokens(data.model, data);
252
+ if (String(data.interactionType || '').trim() === 'conversation-compaction') compactionCalls += 1;
200
253
  totals.modelCalls += 1;
201
254
  if (!allowModel) totals.subagentModelCalls += 1;
202
255
  for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'cost']) {
@@ -222,13 +275,38 @@ function createUsageAccumulator() {
222
275
  }
223
276
  return {
224
277
  add,
278
+ /**
279
+ * A compaction's own model call, from `session.compaction_complete`'s
280
+ * `compactionTokensUsed`: booked unless the runtime reports it, before
281
+ * or after, as an `assistant.usage` of the compaction class.
282
+ */
283
+ addCompaction(tokensUsed, fallbackModel = '') {
284
+ if (!tokensUsed || typeof tokensUsed !== 'object') return;
285
+ addTokens(tokensUsed.model || fallbackModel, tokensUsed, compactionTokens);
286
+ },
225
287
  setQuotaSnapshots(snapshots) {
226
288
  if (snapshots && typeof snapshots === 'object' && Object.keys(snapshots).length) {
227
289
  totals.quotaSnapshots = snapshots;
228
290
  }
229
291
  },
230
292
  snapshot() {
231
- return totals.modelCalls ? { ...totals } : (totals.quotaSnapshots ? { ...totals } : null);
293
+ // A call without a model id is the turn's main model's.
294
+ const models = [];
295
+ const buckets = [...byModel.values(), ...(compactionCalls > 0 ? [] : compactionTokens.values())];
296
+ for (const bucket of buckets) {
297
+ const model = bucket.model || totals.model;
298
+ const same = models.find((entry) => entry.model === model);
299
+ if (!same) {
300
+ models.push({ ...bucket, model });
301
+ continue;
302
+ }
303
+ for (const field of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
304
+ if (bucket[field] !== null) same[field] = (same[field] ?? 0) + bucket[field];
305
+ }
306
+ }
307
+ const tokenModels = models.filter((entry) => entry.model);
308
+ if (!totals.modelCalls && !totals.quotaSnapshots && !tokenModels.length) return null;
309
+ return { ...totals, models: tokenModels };
232
310
  },
233
311
  };
234
312
  }
@@ -242,6 +320,9 @@ export function createCopilotEventNormalizer({
242
320
  // The runner's answer to "is this agent id a background agent?" — shared
243
321
  // across turns, unlike this normalizer's own `backgroundRuns`.
244
322
  isBackgroundAgent = () => false,
323
+ // Called with the raw token counts of every model call (no ids), when the
324
+ // worker runs with OAR_DEBUG_TOKEN_USAGE=1.
325
+ debugTokenUsage = null,
245
326
  } = {}) {
246
327
  let sessionModel = '';
247
328
  let sessionId = '';
@@ -250,7 +331,7 @@ export function createCopilotEventNormalizer({
250
331
  let terminalEmitted = false;
251
332
  let contextUsage = null;
252
333
  let interactionId = '';
253
- const usage = createUsageAccumulator();
334
+ const usage = createUsageAccumulator({ debugTokenUsage });
254
335
 
255
336
  // Assistant text is assembled per `messageId`. A tool-using request produces
256
337
  // several assistant messages (the first typically empty with `toolRequests`,
@@ -866,6 +947,8 @@ export function createCopilotEventNormalizer({
866
947
  return [activityAction('Compacting the conversation context…')];
867
948
  }
868
949
  case 'session.compaction_complete': {
950
+ // Its model call costs tokens too (booked with the turn it ran in).
951
+ usage.addCompaction(data.compactionTokensUsed, data.model || sessionModel);
869
952
  if (data.success === false) {
870
953
  const reason = String(data.error || '').trim();
871
954
  return [activityAction(truncate(reason ? `Context compaction failed: ${reason}` : 'Context compaction failed'))];
@@ -523,6 +523,14 @@ export function createCopilotSdkSessionRunner({
523
523
  // The in-flight ingest POST, exposed only as a test seam. Never awaited by
524
524
  // the turn path — that is the entire point of it.
525
525
  let usagePostChain = Promise.resolve();
526
+ // The token booking's post (POST /api/turn-token-usage), a chain and a test
527
+ // seam like `usagePostChain`. Its run is the row's attempt (a row
528
+ // delivered again is another run of the turn); a row without one (an older
529
+ // relay) is booked under this process's own id, so a turn requeued onto a
530
+ // restarted worker is a second run of it, not the same row.
531
+ let tokenPostChain = Promise.resolve();
532
+ const postedTurnTokens = new WeakSet();
533
+ const tokenRunId = randomUUID();
526
534
  // The last catalog content this worker POSTed to `/api/models/snapshot`,
527
535
  // as a serialized signature. Identity of CONTENT, not of session: an idle
528
536
  // shutdown and resume onto the same catalog must not re-publish it.
@@ -701,6 +709,12 @@ export function createCopilotSdkSessionRunner({
701
709
  // card), so they hold the stall watchdog and the idle shutdown too — but
702
710
  // not steering: nobody is answering a card here.
703
711
  let pendingRelayToolCalls = 0;
712
+ // Deliveries `handlePendingPayload` is still working on. Most become (or
713
+ // steer into) `activeTurn` at once, but a steer that waits on the turn's
714
+ // send, its row or the relay context can find the turn gone and open its
715
+ // own afterwards: in that gap no turn exists, and the worker must still
716
+ // not read as replaceable (`releaseHold`).
717
+ let admittingDeliveries = 0;
704
718
  // `relay_tool` is registered only while this relay has paired remotes.
705
719
  // Asked once when the worker starts; a session build waits for that first
706
720
  // answer (it is async anyway), and every runtime stop asks again so the
@@ -1330,6 +1344,7 @@ export function createCopilotSdkSessionRunner({
1330
1344
  conversationId: message.conversationId,
1331
1345
  sdkSessionId,
1332
1346
  messageId: message.id,
1347
+ attemptId: message.attemptId || null,
1333
1348
  model: result?.model || appliedModel() || defaultModel || '',
1334
1349
  usage,
1335
1350
  contextUsage,
@@ -1352,6 +1367,7 @@ export function createCopilotSdkSessionRunner({
1352
1367
  hasQuotaSnapshots: !!usage?.quotaSnapshots,
1353
1368
  cfiOverage: usage?.quotaSnapshots?.cfi_overage ?? null,
1354
1369
  }));
1370
+ return lastTurnUsage;
1355
1371
  }
1356
1372
 
1357
1373
  /**
@@ -1372,9 +1388,10 @@ export function createCopilotSdkSessionRunner({
1372
1388
  * row still open — for as long as the socket stayed up. The result is unused
1373
1389
  * and the failure is already swallowed, so there was nothing to wait for.
1374
1390
  *
1375
- * BYOK sessions do not post at all: they spend the user's own OpenAI key
1391
+ * BYOK sessions do not post here: they spend the user's own OpenAI key
1376
1392
  * rather than Copilot quota, and their usage events report `cost: 0`, so
1377
- * their numbers would only mislead on a card about the Copilot plan.
1393
+ * their numbers would only mislead on a card about the Copilot plan. Their
1394
+ * tokens still go to the token booking (postTurnTokens).
1378
1395
  */
1379
1396
  function postTurnUsage() {
1380
1397
  if (byokProvider || !lastTurnUsage || lastTurnUsage === postedTurnUsage) return;
@@ -1385,6 +1402,39 @@ export function createCopilotSdkSessionRunner({
1385
1402
  .catch((error) => { dbg('usage ingest failed', error?.message || String(error)); });
1386
1403
  }
1387
1404
 
1405
+ /**
1406
+ * Report a turn's own captured tokens per model to the token booking,
1407
+ * from that turn's capture: a continuation that captured its
1408
+ * usage meanwhile cannot take the turn's place. Fire-and-forget like the plan usage, but BYOK sessions post
1409
+ * too, under their own billing (`byok-<provider type>`), which the relay
1410
+ * keeps apart from the plan's. No cost: the plan bills in AI units, not
1411
+ * dollars, and an own key's calls report 0.
1412
+ */
1413
+ function postTurnTokens(capture) {
1414
+ if (!capture || postedTurnTokens.has(capture)) return;
1415
+ postedTurnTokens.add(capture);
1416
+ const models = (capture.usage?.models || []).map((entry) => ({
1417
+ model: entry.model,
1418
+ inputTokens: entry.inputTokens,
1419
+ outputTokens: entry.outputTokens,
1420
+ cacheWriteTokens: entry.cacheWriteTokens,
1421
+ cacheReadTokens: entry.cacheReadTokens,
1422
+ reasoningTokens: entry.reasoningTokens,
1423
+ }));
1424
+ if (!models.length || !capture.messageId) return;
1425
+ const billing = byokProvider ? `byok-${String(byokProvider.type || 'openai').trim().toLowerCase()}` : '';
1426
+ tokenPostChain = api('POST', '/api/turn-token-usage', {
1427
+ conversationId: capture.conversationId,
1428
+ turnId: capture.messageId,
1429
+ runId: capture.attemptId || tokenRunId,
1430
+ provider: 'github',
1431
+ billing,
1432
+ ...(sdkSessionId ? { nativeSessionId: sdkSessionId } : {}),
1433
+ at: capture.capturedAt,
1434
+ models,
1435
+ }).catch((error) => { dbg('turn token ingest failed', error?.message || String(error)); });
1436
+ }
1437
+
1388
1438
  /**
1389
1439
  * Publish the session's model catalog to `/api/models/snapshot` (Phase 5A:
1390
1440
  * with the extension retired, worker snapshots and the server-side discovery
@@ -3161,7 +3211,14 @@ export function createCopilotSdkSessionRunner({
3161
3211
  model: '',
3162
3212
  // The runner's cross-turn knowledge of background agents rides in, so a
3163
3213
  // later turn's normalizer opens no lane for an agent spawned earlier.
3164
- normalizer: createNormalizerImpl({ isBackgroundAgent: (agentId) => agentDetails.has(agentId) }),
3214
+ normalizer: createNormalizerImpl({
3215
+ isBackgroundAgent: (agentId) => agentDetails.has(agentId),
3216
+ // The raw counts of every model call, for checking the token rule
3217
+ // against a real provider (counts only).
3218
+ ...(String(env?.OAR_DEBUG_TOKEN_USAGE || '').trim() === '1'
3219
+ ? { debugTokenUsage: (fields) => dbg('token usage', JSON.stringify(fields)) }
3220
+ : {}),
3221
+ }),
3165
3222
  result: null,
3166
3223
  failure: null,
3167
3224
  settled: false,
@@ -3730,7 +3787,7 @@ export function createCopilotSdkSessionRunner({
3730
3787
  const failure = turn.failure;
3731
3788
  // Capture only. The POST fires from driveTurn's `finally`, after the rows
3732
3789
  // have been published.
3733
- captureTurnUsage(turn.primaryEntry.message, result);
3790
+ turn.usageCapture = captureTurnUsage(turn.primaryEntry.message, result) || null;
3734
3791
 
3735
3792
  const pending = turn.entries.filter((entry) => !entry.settled && entry.turn === turn);
3736
3793
  if (!pending.length) return;
@@ -4166,6 +4223,7 @@ export function createCopilotSdkSessionRunner({
4166
4223
  syncDeliveryReadiness();
4167
4224
  // The ingest is advisory, never awaited, and must never be able to
4168
4225
  // delay a reply that is already written.
4226
+ postTurnTokens(turn.usageCapture);
4169
4227
  postTurnUsage();
4170
4228
  }
4171
4229
  }
@@ -4687,7 +4745,62 @@ export function createCopilotSdkSessionRunner({
4687
4745
  const canSteer = canAcceptSteering();
4688
4746
  const holdReason = turnActive && !canSteer ? steeringHoldReason(turn) : null;
4689
4747
  const messageId = String(turn?.currentOwner?.message?.id || turn?.message?.id || '').trim() || null;
4690
- return { turnActive, canSteer, holdReason, messageId, supported: true, cancellableIds: cancellableIds() };
4748
+ // `releasable`: the relay may stop this worker and launch a fresh one now
4749
+ // (its recycle after a deploy), the same fields the Claude worker sends.
4750
+ const hold = releaseHold();
4751
+ return {
4752
+ turnActive,
4753
+ canSteer,
4754
+ holdReason,
4755
+ messageId,
4756
+ supported: true,
4757
+ cancellableIds: cancellableIds(),
4758
+ releasable: !hold,
4759
+ releaseHold: hold || null,
4760
+ // As the task panel counts them: with the registry it is the truth, and
4761
+ // the event-scraped shell set would count its shells a second time.
4762
+ backgroundTaskCount: sessionRpc.tasks ? backgroundTasks.size : backgroundShells.size(),
4763
+ // The runtime process (the CLI child) is up: a client or a session
4764
+ // handle exists until `stopRuntime` clears both. The relay's recycle
4765
+ // replaces a stale worker only once it is releasable AND this is false;
4766
+ // releasable with the runtime up is the idle window, left to the idle
4767
+ // shutdown.
4768
+ cliAlive: Boolean(client || session),
4769
+ };
4770
+ }
4771
+
4772
+ /**
4773
+ * What keeps this worker from being replaced now ('' when nothing does):
4774
+ * the idle shutdown's gates (`evaluateLifecycle`) and the runtime tree's
4775
+ * busy check, named with the relay's hold words. Stopping the worker ends
4776
+ * the runtime, and with it the turn, the card a human is answering, a
4777
+ * compaction, and every background shell and agent.
4778
+ *
4779
+ * - 'question': a question card or an ask-mode approval is open;
4780
+ * - 'compaction': the runtime is compacting the session;
4781
+ * - 'turn': a turn is running or still publishing its rows, a `relay_tool`
4782
+ * call is out, an image row is on the relay's image routes, or a
4783
+ * delivery is on its way in;
4784
+ * - 'background': a background shell or agent is live, a settled shell's
4785
+ * continuation is due (`continuationGraceMs`), a registry read is due
4786
+ * or under way, or the task registry has failed to answer: in the last
4787
+ * two "nothing is running" is not known (`backgroundWorkHoldsRuntime`
4788
+ * holds the runtime on the same grounds).
4789
+ */
4790
+ function releaseHold() {
4791
+ if (pendingHumanRequests > 0) return 'question';
4792
+ if (isCompacting()) return 'compaction';
4793
+ if (
4794
+ activeTurn || publishingTurns.size > 0 || pendingRelayToolCalls > 0
4795
+ || directImageMessages.size > 0 || admittingDeliveries > 0
4796
+ ) return 'turn';
4797
+ if (backgroundShells.size() > 0 || backgroundTasks.size > 0) return 'background';
4798
+ if (continuationDueSince && Date.now() - continuationDueSince < continuationGraceMs) return 'background';
4799
+ // A read of the registry that is debounced, queued or on its way may
4800
+ // still find a live agent or shell the last answer did not have.
4801
+ if (sessionRpc.tasks && (taskRefreshTimer || taskRefreshQueued > 0 || taskRefreshInFlight)) return 'background';
4802
+ if (sessionRpc.tasks && taskRefreshFailures > 0 && taskRefreshFailures < MAX_TASK_REFRESH_FAILURES) return 'background';
4803
+ return '';
4691
4804
  }
4692
4805
 
4693
4806
  /**
@@ -4831,6 +4944,18 @@ export function createCopilotSdkSessionRunner({
4831
4944
  }
4832
4945
 
4833
4946
  async function handlePendingPayload(pending) {
4947
+ // A payload without a message is refused at once, as it always was: it
4948
+ // admits nothing.
4949
+ if (!pending?.message) return false;
4950
+ admittingDeliveries += 1;
4951
+ try {
4952
+ return await admitPendingPayload(pending);
4953
+ } finally {
4954
+ admittingDeliveries -= 1;
4955
+ }
4956
+ }
4957
+
4958
+ async function admitPendingPayload(pending) {
4834
4959
  const message = pending?.message || null;
4835
4960
  if (!message) return false;
4836
4961
  // An image model is no chat model: its row goes to the relay's image
@@ -5024,6 +5149,7 @@ export function createCopilotSdkSessionRunner({
5024
5149
  // never awaits this, which is what keeps a slow relay from holding a
5025
5150
  // finished reply.
5026
5151
  whenUsagePosted: () => usagePostChain,
5152
+ whenTokensPosted: () => tokenPostChain,
5027
5153
  // The in-flight model-catalog snapshot POST — the same kind of seam.
5028
5154
  whenModelSnapshotPosted: () => modelSnapshotChain,
5029
5155
  // The in-flight queued-lane read — same kind of seam.