@oar-sh/oar 0.9.19 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. package/shared/turn-token-usage.mjs +144 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.19",
3
+ "version": "0.9.20",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "!server/README.md",
26
26
  "!server/data/",
27
27
  "!server/logs/",
28
+ "!server/backups/",
28
29
  "!server/config.json",
29
30
  "!server/uploads/",
30
31
  "server/uploads/README.md",
@@ -43,8 +44,9 @@
43
44
  "preinstall": "node server/services/package-data-rescue-preinstall.mjs",
44
45
  "start": "node server/server.js",
45
46
  "copilot:relay": "gh copilot -- --allow-all",
46
- "test": "node --import ./scripts/node-test-keep-alive.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
47
+ "test": "node --import ./scripts/node-test-keep-alive.mjs --import ./scripts/node-test-worktree-guard.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
47
48
  "test:agents:smoke": "node --test \"tests/agents/**/*.test.mjs\"",
49
+ "test:packed-update": "node --test tests/packed-update/packed-update.test.mjs",
48
50
  "test:e2e": "node tests/run-e2e.mjs",
49
51
  "setup:git": "node scripts/setup-git.mjs",
50
52
  "land": "node scripts/land.mjs",
@@ -1,4 +1,5 @@
1
1
  import { capThought } from '../../shared/thought-cap.mjs';
2
+ import { tokenUsageModel } from '../../shared/turn-token-usage.mjs';
2
3
  import {
3
4
  compactBoundaryActivityAction,
4
5
  displayToolName,
@@ -68,6 +69,57 @@ export function readCloudResult(payload) {
68
69
  };
69
70
  }
70
71
 
72
+ /**
73
+ * A cloud session's running token totals per model, for the token booking
74
+ * (POST /api/turn-token-usage with `sessionTotals`; the relay books what
75
+ * they grew by): `[{ model, inputTokens, cacheWriteTokens, cacheReadTokens,
76
+ * outputTokens, costUsd, costSource, costUnknown }]` out of a result's
77
+ * `modelUsage`. A field the entry does not carry is null, not 0. The cache
78
+ * fields of the session read (`sessionTokens`, readCloudSessionUsage) cover
79
+ * the whole session, so they stand in only when the session ran one model;
80
+ * with more, a missing field stays null. Model ids drop the `[1m]` tier.
81
+ */
82
+ export function cloudSessionTokenTotals(modelUsage, sessionTokens = null) {
83
+ if (!modelUsage || typeof modelUsage !== 'object' || Array.isArray(modelUsage)) return [];
84
+ const count = (value) => {
85
+ const n = finiteOrNull(value);
86
+ return n === null || n < 0 ? null : Math.trunc(n);
87
+ };
88
+ const totals = new Map();
89
+ for (const [name, entry] of Object.entries(modelUsage)) {
90
+ const model = tokenUsageModel(name);
91
+ if (!model || !entry || typeof entry !== 'object') continue;
92
+ const costUsd = finiteOrNull(entry.costUSD);
93
+ const next = {
94
+ model,
95
+ inputTokens: count(entry.inputTokens),
96
+ cacheWriteTokens: count(entry.cacheCreationInputTokens),
97
+ cacheReadTokens: count(entry.cacheReadInputTokens),
98
+ outputTokens: count(entry.outputTokens),
99
+ costUsd: costUsd === null || costUsd < 0 ? null : costUsd,
100
+ costSource: null,
101
+ costUnknown: entry.costBasis === 'unknown',
102
+ };
103
+ const current = totals.get(model);
104
+ if (!current) {
105
+ totals.set(model, next);
106
+ continue;
107
+ }
108
+ for (const field of ['inputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'outputTokens', 'costUsd']) {
109
+ if (next[field] !== null) current[field] = (current[field] ?? 0) + next[field];
110
+ }
111
+ current.costUnknown = current.costUnknown || next.costUnknown;
112
+ }
113
+ const entries = [...totals.values()];
114
+ if (entries.length === 1 && sessionTokens) {
115
+ const [only] = entries;
116
+ if (only.cacheWriteTokens === null) only.cacheWriteTokens = count(sessionTokens.cacheWriteTokens);
117
+ if (only.cacheReadTokens === null) only.cacheReadTokens = count(sessionTokens.cacheReadTokens);
118
+ }
119
+ for (const entry of entries) entry.costSource = entry.costUsd === null ? null : 'claude-code';
120
+ return entries;
121
+ }
122
+
71
123
  /**
72
124
  * What `GET /v1/code/sessions/{id}` says after a turn, for the relay's usage
73
125
  * routes: the context occupancy in the shape `/api/claude-context-usage`
@@ -99,6 +151,14 @@ export function readCloudSessionUsage(sessionBody, { model = '' } = {}) {
99
151
  };
100
152
  return {
101
153
  contextUsage,
154
+ // The session's token totals over every model: what the token booking
155
+ // falls back on for the cache fields (cloudSessionTokenTotals).
156
+ tokens: {
157
+ inputTokens: finiteOrNull(usage.input_tokens),
158
+ outputTokens: finiteOrNull(usage.output_tokens),
159
+ cacheReadTokens: finiteOrNull(usage.cache_read_tokens),
160
+ cacheWriteTokens: finiteOrNull(usage.cache_write_tokens),
161
+ },
102
162
  costUsd: finiteOrNull(usage.cost_usd),
103
163
  sessionUrl: String(shape?.session_url || sessionBody?.sessionUrl || '').trim() || null,
104
164
  workerStatus: String(shape?.worker_status || '').trim().toLowerCase() || null,
@@ -18,6 +18,7 @@ import {
18
18
  } from '../claude-worker/claude-turn-publisher.mjs';
19
19
  import { summarizeToolInput } from '../claude-worker/sdk-message-normalizer.mjs';
20
20
  import {
21
+ cloudSessionTokenTotals,
21
22
  createClaudeCloudEventNormalizer,
22
23
  readCloudResult,
23
24
  readCloudSessionUsage,
@@ -1180,6 +1181,32 @@ export function createClaudeCloudSessionRunner({
1180
1181
  state.contextUsage = usage.contextUsage;
1181
1182
  await publisher.publishContextUsage({ message, state, model, sdkSessionId });
1182
1183
  await publisher.publishPlanUsage({ message, state, sdkSessionId });
1184
+ await publishTurnTokens(message, state, usage.tokens);
1185
+ }
1186
+
1187
+ // A worker process names its run once: a turn requeued onto a restarted
1188
+ // worker is another run of it (POST /api/turn-token-usage).
1189
+ const tokenRunId = crypto.randomUUID();
1190
+
1191
+ /**
1192
+ * The session's running token totals per model (the result's
1193
+ * `modelUsage`), under the turn: the relay books what they grew by since
1194
+ * the session's other turns. Advisory; nothing without `modelUsage`.
1195
+ */
1196
+ async function publishTurnTokens(message, state, sessionTokens) {
1197
+ const sessionTotals = cloudSessionTokenTotals(state.result?.modelUsage, sessionTokens);
1198
+ if (!sessionTotals.length || !session.id) return;
1199
+ await api('POST', '/api/turn-token-usage', {
1200
+ conversationId: message.conversationId,
1201
+ turnId: String(message.id || ''),
1202
+ runId: tokenRunId,
1203
+ provider: 'claude-cloud',
1204
+ nativeSessionId: session.id,
1205
+ at: new Date().toISOString(),
1206
+ sessionTotals,
1207
+ }).catch((error) => {
1208
+ dbg('turn token publish failed', error?.message || String(error));
1209
+ });
1183
1210
  }
1184
1211
 
1185
1212
  async function finishTurn(t, outcome) {
@@ -75,6 +75,7 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
75
75
  group: z.string().nullable().optional().describe(FIELDS.group.description),
76
76
  auto_compact_window: number('auto_compact_window'),
77
77
  thinking_enabled: z.boolean().optional().describe(FIELDS.thinking_enabled.description),
78
+ planOnly: z.boolean().optional().describe(FIELDS.planOnly.description),
78
79
  thinking_display: choice('thinking_display', REMOTE_RELAY_THINKING_DISPLAYS),
79
80
  question_id: text('question_id'),
80
81
  // ask: the contract's validator refuses a question or detail that is too
@@ -615,6 +615,9 @@ export function createClaudeSessionRunner({
615
615
  // Both Claude billings report to the Claude API booking; DeepSeek has
616
616
  // its own.
617
617
  ...(claudeBillingOf(workerKind) ? { billingUsageRoute: '/api/claude-api-usage', billingKind: workerKind } : {}),
618
+ // Every Claude-family worker books its turns' tokens.
619
+ turnTokenProvider: workerKind,
620
+ ...(typeof backend?.turnTokenCost === 'function' ? { turnTokenCost: backend.turnTokenCost } : {}),
618
621
  ...(backend
619
622
  ? {
620
623
  describeFailure: backend.describeFailure,
@@ -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'))];