@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
@@ -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.
@@ -1330,6 +1338,7 @@ export function createCopilotSdkSessionRunner({
1330
1338
  conversationId: message.conversationId,
1331
1339
  sdkSessionId,
1332
1340
  messageId: message.id,
1341
+ attemptId: message.attemptId || null,
1333
1342
  model: result?.model || appliedModel() || defaultModel || '',
1334
1343
  usage,
1335
1344
  contextUsage,
@@ -1352,6 +1361,7 @@ export function createCopilotSdkSessionRunner({
1352
1361
  hasQuotaSnapshots: !!usage?.quotaSnapshots,
1353
1362
  cfiOverage: usage?.quotaSnapshots?.cfi_overage ?? null,
1354
1363
  }));
1364
+ return lastTurnUsage;
1355
1365
  }
1356
1366
 
1357
1367
  /**
@@ -1372,9 +1382,10 @@ export function createCopilotSdkSessionRunner({
1372
1382
  * row still open — for as long as the socket stayed up. The result is unused
1373
1383
  * and the failure is already swallowed, so there was nothing to wait for.
1374
1384
  *
1375
- * BYOK sessions do not post at all: they spend the user's own OpenAI key
1385
+ * BYOK sessions do not post here: they spend the user's own OpenAI key
1376
1386
  * 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.
1387
+ * their numbers would only mislead on a card about the Copilot plan. Their
1388
+ * tokens still go to the token booking (postTurnTokens).
1378
1389
  */
1379
1390
  function postTurnUsage() {
1380
1391
  if (byokProvider || !lastTurnUsage || lastTurnUsage === postedTurnUsage) return;
@@ -1385,6 +1396,39 @@ export function createCopilotSdkSessionRunner({
1385
1396
  .catch((error) => { dbg('usage ingest failed', error?.message || String(error)); });
1386
1397
  }
1387
1398
 
1399
+ /**
1400
+ * Report a turn's own captured tokens per model to the token booking,
1401
+ * from that turn's capture: a continuation that captured its
1402
+ * usage meanwhile cannot take the turn's place. Fire-and-forget like the plan usage, but BYOK sessions post
1403
+ * too, under their own billing (`byok-<provider type>`), which the relay
1404
+ * keeps apart from the plan's. No cost: the plan bills in AI units, not
1405
+ * dollars, and an own key's calls report 0.
1406
+ */
1407
+ function postTurnTokens(capture) {
1408
+ if (!capture || postedTurnTokens.has(capture)) return;
1409
+ postedTurnTokens.add(capture);
1410
+ const models = (capture.usage?.models || []).map((entry) => ({
1411
+ model: entry.model,
1412
+ inputTokens: entry.inputTokens,
1413
+ outputTokens: entry.outputTokens,
1414
+ cacheWriteTokens: entry.cacheWriteTokens,
1415
+ cacheReadTokens: entry.cacheReadTokens,
1416
+ reasoningTokens: entry.reasoningTokens,
1417
+ }));
1418
+ if (!models.length || !capture.messageId) return;
1419
+ const billing = byokProvider ? `byok-${String(byokProvider.type || 'openai').trim().toLowerCase()}` : '';
1420
+ tokenPostChain = api('POST', '/api/turn-token-usage', {
1421
+ conversationId: capture.conversationId,
1422
+ turnId: capture.messageId,
1423
+ runId: capture.attemptId || tokenRunId,
1424
+ provider: 'github',
1425
+ billing,
1426
+ ...(sdkSessionId ? { nativeSessionId: sdkSessionId } : {}),
1427
+ at: capture.capturedAt,
1428
+ models,
1429
+ }).catch((error) => { dbg('turn token ingest failed', error?.message || String(error)); });
1430
+ }
1431
+
1388
1432
  /**
1389
1433
  * Publish the session's model catalog to `/api/models/snapshot` (Phase 5A:
1390
1434
  * with the extension retired, worker snapshots and the server-side discovery
@@ -3161,7 +3205,14 @@ export function createCopilotSdkSessionRunner({
3161
3205
  model: '',
3162
3206
  // The runner's cross-turn knowledge of background agents rides in, so a
3163
3207
  // later turn's normalizer opens no lane for an agent spawned earlier.
3164
- normalizer: createNormalizerImpl({ isBackgroundAgent: (agentId) => agentDetails.has(agentId) }),
3208
+ normalizer: createNormalizerImpl({
3209
+ isBackgroundAgent: (agentId) => agentDetails.has(agentId),
3210
+ // The raw counts of every model call, for checking the token rule
3211
+ // against a real provider (counts only).
3212
+ ...(String(env?.OAR_DEBUG_TOKEN_USAGE || '').trim() === '1'
3213
+ ? { debugTokenUsage: (fields) => dbg('token usage', JSON.stringify(fields)) }
3214
+ : {}),
3215
+ }),
3165
3216
  result: null,
3166
3217
  failure: null,
3167
3218
  settled: false,
@@ -3730,7 +3781,7 @@ export function createCopilotSdkSessionRunner({
3730
3781
  const failure = turn.failure;
3731
3782
  // Capture only. The POST fires from driveTurn's `finally`, after the rows
3732
3783
  // have been published.
3733
- captureTurnUsage(turn.primaryEntry.message, result);
3784
+ turn.usageCapture = captureTurnUsage(turn.primaryEntry.message, result) || null;
3734
3785
 
3735
3786
  const pending = turn.entries.filter((entry) => !entry.settled && entry.turn === turn);
3736
3787
  if (!pending.length) return;
@@ -4166,6 +4217,7 @@ export function createCopilotSdkSessionRunner({
4166
4217
  syncDeliveryReadiness();
4167
4218
  // The ingest is advisory, never awaited, and must never be able to
4168
4219
  // delay a reply that is already written.
4220
+ postTurnTokens(turn.usageCapture);
4169
4221
  postTurnUsage();
4170
4222
  }
4171
4223
  }
@@ -5024,6 +5076,7 @@ export function createCopilotSdkSessionRunner({
5024
5076
  // never awaits this, which is what keeps a slow relay from holding a
5025
5077
  // finished reply.
5026
5078
  whenUsagePosted: () => usagePostChain,
5079
+ whenTokensPosted: () => tokenPostChain,
5027
5080
  // The in-flight model-catalog snapshot POST — the same kind of seam.
5028
5081
  whenModelSnapshotPosted: () => modelSnapshotChain,
5029
5082
  // The in-flight queued-lane read — same kind of seam.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { buildCursorUserMessage } from './cursor-attachments.mjs';
2
3
  import { buildCursorContextUsage } from './cursor-context-usage.mjs';
3
4
  import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
@@ -23,6 +24,7 @@ import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instr
23
24
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
24
25
  import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
25
26
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
27
+ import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
26
28
  import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
27
29
 
28
30
  // Stale-auth recovery budget per turn: expiry is routine on long-lived
@@ -34,12 +36,6 @@ const MAX_STALE_AUTH_RETRIES = 2;
34
36
  // exchanged auth goes stale around the 4h mark while the API key stays valid.
35
37
  const STALE_HANDLE_IDLE_MS = 30 * 60_000;
36
38
 
37
- const PLAN_BOARD_ACTIONS = [
38
- { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
39
- { id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
40
- { id: 'exit_only', label: 'Stop here', mode: 'agent' },
41
- ];
42
-
43
39
  // The Cursor SDK's SendOptions carries only model/mode/callbacks — there is
44
40
  // no per-turn instruction channel — so ask/autopilot steering rides on the
45
41
  // user message text, mirroring the Copilot extension's prompt-context
@@ -447,6 +443,45 @@ export function createCursorTurnRunner({
447
443
  });
448
444
  }
449
445
 
446
+ // A process without the row's attempt id names its run once (POST
447
+ // /api/turn-token-usage): a turn requeued onto a restarted worker is a
448
+ // second run of it.
449
+ const tokenRunId = randomUUID();
450
+
451
+ /**
452
+ * Ship the turn's tokens to the token booking: the SDK's usage
453
+ * for the turn, all its model calls together, under the turn's model.
454
+ * Cursor counts fresh input apart from the cache (cursor-context-usage.mjs
455
+ * adds the three up). No cost: the SDK prices no single turn. A field the
456
+ * SDK left out stays out, and the relay keeps it unknown. Fire-and-forget:
457
+ * it never throws and the turn does not wait on it.
458
+ */
459
+ async function publishTurnTokens(message, state, model, normalizer) {
460
+ try {
461
+ const usage = state.lastUsage || normalizer?.lastUsage || null;
462
+ const turnModel = state.responseModel || model || '';
463
+ if (!usage || typeof usage !== 'object' || !turnModel || !message?.id) return;
464
+ await api('POST', '/api/turn-token-usage', {
465
+ conversationId: message.conversationId,
466
+ turnId: String(message.id),
467
+ runId: message.attemptId || tokenRunId,
468
+ provider: 'cursor',
469
+ ...(agentHandle?.agentId ? { nativeSessionId: agentHandle.agentId } : {}),
470
+ at: new Date().toISOString(),
471
+ models: [{
472
+ model: turnModel,
473
+ inputTokens: usage.inputTokens,
474
+ outputTokens: usage.outputTokens,
475
+ cacheWriteTokens: usage.cacheWriteTokens,
476
+ cacheReadTokens: usage.cacheReadTokens,
477
+ reasoningTokens: usage.reasoningTokens,
478
+ }],
479
+ });
480
+ } catch (error) {
481
+ dbg('turn token publish failed', error?.message || String(error));
482
+ }
483
+ }
484
+
450
485
  /**
451
486
  * Report the agent's cumulative billed usage so the relay can book this
452
487
  * turn's increase against the configured monthly allowance. The model is
@@ -704,6 +739,7 @@ export function createCursorTurnRunner({
704
739
  // (the auth retry swaps it), so a discarded attempt's usage is not
705
740
  // republished over the retry's.
706
741
  await publishContextUsage(message, state, model, normalizer);
742
+ void publishTurnTokens(message, state, model, normalizer);
707
743
  await publishPlanUsage(message, state, model);
708
744
  }
709
745
 
@@ -40,6 +40,8 @@ import { migrateQueueProvider } from './migrations/0031-queue-provider.mjs';
40
40
  import { migrateRuntimeSessionBillingSwitch } from './migrations/0032-runtime-session-billing-switch.mjs';
41
41
  import { migrateClaudeApiUsageCheckpoints } from './migrations/0033-claude-api-usage-checkpoints.mjs';
42
42
  import { migrateClaudeApiUsageTotals } from './migrations/0034-claude-api-usage-totals.mjs';
43
+ import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan-only.mjs';
44
+ import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
43
45
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
44
46
 
45
47
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -1059,6 +1061,22 @@ try {
1059
1061
  console.warn(`[claude-api-usage] migration 0034 failed; retrying on next boot: ${error?.message || error}`);
1060
1062
  }
1061
1063
 
1064
+ // A plan-only conversation, handled like 0020.
1065
+ try {
1066
+ migrateConversationPlanOnly(db);
1067
+ } catch (error) {
1068
+ console.warn(`[plan-only] migration 0035 failed; retrying on next boot: ${error?.message || error}`);
1069
+ }
1070
+
1071
+ // Tokens per turn and per day for every provider, with a one-time
1072
+ // copy of the Claude API and DeepSeek days. After 0025 and 0034: a missing
1073
+ // source throws here, so it is logged and retried next boot like 0034.
1074
+ try {
1075
+ migrateTokenUsage(db);
1076
+ } catch (error) {
1077
+ console.warn(`[token-usage] migration 0036 failed; retrying on next boot: ${error?.message || error}`);
1078
+ }
1079
+
1062
1080
  const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
1063
1081
  if (!conversationColumns.includes('archived')) {
1064
1082
  db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
@@ -35,6 +35,7 @@
35
35
  // (api-docs.deepseek.com, quick_start/agent_integrations/claude_code).
36
36
 
37
37
  import { deepseekAnthropicBaseUrl } from '../../shared/deepseek-api.mjs';
38
+ import { estimateCost } from '../../shared/deepseek-pricing.mjs';
38
39
  import { isHostClaudeSessionVariable } from '../../shared/host-claude-session-env.mjs';
39
40
  import { CLAUDE_CLI_KEY_CHAT_SETTINGS, claudeCliPinnedEnv } from '../claude-worker/claude-cli-pinned-env.mjs';
40
41
  import {
@@ -262,6 +263,19 @@ export function createDeepSeekClaudeBackend({ env = process.env } = {}) {
262
263
  // relay's model ids (relayModel); the relay books what each report adds.
263
264
  // Claude Code's `total_cost_usd` is at Claude's prices and is not sent.
264
265
  usageRoute: '/api/deepseek-plan-usage',
266
+ // A turn's tokens (POST /api/turn-token-usage) are priced at DeepSeek's
267
+ // own rates for the time, as the card's estimate is: cache writes and
268
+ // fresh input are both misses. Null for a model the table has no price for.
269
+ turnTokenCost: (entry, at) => {
270
+ const costUsd = estimateCost({
271
+ model: entry.model,
272
+ at,
273
+ hitTokens: entry.cacheReadTokens,
274
+ missTokens: entry.inputTokens + entry.cacheWriteTokens,
275
+ outputTokens: entry.outputTokens,
276
+ });
277
+ return { costUsd, costSource: costUsd === null ? null : 'oar-deepseek-table' };
278
+ },
265
279
 
266
280
  // A Claude id is not a model of DeepSeek's to request, only one it may
267
281
  // have served (relayModel).
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import {
2
3
  createGrokAgentHandle,
3
4
  startGrokTurn,
@@ -11,15 +12,10 @@ import { createPreviewInstructionsProvider } from '../../shared/preview-instruct
11
12
  import { fetchRemoteRelayInflight } from '../../shared/remote-relay-tool-core.mjs';
12
13
  import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
13
14
  import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
15
+ import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
14
16
  import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
15
17
  import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
16
18
 
17
- const PLAN_BOARD_ACTIONS = [
18
- { id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
19
- { id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
20
- { id: 'exit_only', label: 'Stop here', mode: 'agent' },
21
- ];
22
-
23
19
  // Grok has no exit-plan tool, so the only board source is the plan-mode
24
20
  // text-shape fallback; the payload matches the Claude/Cursor workers'.
25
21
  export function buildGrokPlanReadyBoardPayload({
@@ -352,6 +348,47 @@ export function createGrokTurnRunner({
352
348
  });
353
349
  }
354
350
 
351
+ // A process without the row's attempt id names its run once (POST
352
+ // /api/turn-token-usage): a turn requeued onto a restarted worker is a
353
+ // second run of it.
354
+ const tokenRunId = randomUUID();
355
+
356
+ /**
357
+ * Ship the turn's tokens to the token booking: per model where
358
+ * the agent breaks them down, else the turn's totals under its model, with
359
+ * the agent's own cost. Grok counts fresh input apart from the cache
360
+ * (grok-context-usage.mjs adds the three up). Advisory, fire-and-forget.
361
+ */
362
+ async function publishTurnTokens(message, state, model) {
363
+ const usage = state.turnUsage;
364
+ if (!usage || !message?.id) return;
365
+ const entry = (source, entryModel) => ({
366
+ model: entryModel,
367
+ inputTokens: source.inputTokens,
368
+ outputTokens: source.outputTokens,
369
+ cacheWriteTokens: source.cacheCreationTokens,
370
+ cacheReadTokens: source.cachedReadTokens,
371
+ reasoningTokens: source.reasoningTokens,
372
+ ...(source.costUsd !== null && source.costUsd !== undefined ? { costUsd: source.costUsd, costSource: 'provider' } : {}),
373
+ });
374
+ const turnModel = state.responseModel || usage.modelId || model || '';
375
+ const models = Array.isArray(usage.modelUsage) && usage.modelUsage.length
376
+ ? usage.modelUsage.map((item) => entry(item, item.model))
377
+ : (turnModel ? [entry(usage, turnModel)] : []);
378
+ if (!models.length) return;
379
+ await api('POST', '/api/turn-token-usage', {
380
+ conversationId: message.conversationId,
381
+ turnId: String(message.id),
382
+ runId: message.attemptId || tokenRunId,
383
+ provider: 'grok',
384
+ ...(sdkSessionId ? { nativeSessionId: sdkSessionId } : {}),
385
+ at: usage.capturedAt || new Date().toISOString(),
386
+ models,
387
+ }).catch((error) => {
388
+ dbg('turn token publish failed', error?.message || String(error));
389
+ });
390
+ }
391
+
355
392
  /**
356
393
  * Ship per-turn token/cost totals for the Check Usage Grok card. Advisory:
357
394
  * failures never disturb the turn reply.
@@ -521,6 +558,7 @@ export function createGrokTurnRunner({
521
558
  // awaits made it false.
522
559
  void publishContextUsage(message, state, model).catch(() => {});
523
560
  void publishPlanUsage(message, state).catch(() => {});
561
+ void publishTurnTokens(message, state, model).catch(() => {});
524
562
  }
525
563
 
526
564
  if (aborted) {
@@ -12,13 +12,14 @@
12
12
  * Safe to run repeatedly (idempotent) and against a live database — ADD COLUMN
13
13
  * only takes a brief write lock and new columns default to NULL.
14
14
  *
15
- * Usage: node server/migrations/0001-add-structured-answer.mjs [path/to/copilot.db]
15
+ * Usage: node server/migrations/0001-add-structured-answer.mjs [path/to/oar.db]
16
16
  */
17
17
 
18
18
  import { fileURLToPath } from 'url';
19
19
  import path from 'path';
20
20
  import process from 'process';
21
21
  import Database from 'better-sqlite3';
22
+ import { cliDbPath } from '../services/database-file.mjs';
22
23
 
23
24
  const NEW_COLUMNS = [
24
25
  { name: 'structured_answer', ddl: 'ALTER TABLE relay_questions ADD COLUMN structured_answer TEXT' },
@@ -56,15 +57,12 @@ export function migrate(dbPath) {
56
57
  }
57
58
  }
58
59
 
59
- function defaultDbPath() {
60
- const here = path.dirname(fileURLToPath(import.meta.url));
61
- return path.resolve(here, '..', 'data', 'copilot.db');
62
- }
63
-
64
60
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
65
61
  if (isMain) {
66
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
67
62
  try {
63
+ // The given path, else the database in server/data (oar.db, else the
64
+ // name before 0.9.20); never a new one.
65
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
68
66
  const result = migrate(dbPath);
69
67
  const applied = result.applied.length ? result.applied.join(', ') : '(none)';
70
68
  const skipped = result.skipped.length ? result.skipped.join(', ') : '(none)';
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Idempotent: re-running once path_key exists is a no-op.
20
20
  *
21
- * Usage: node server/migrations/0002-recent-workspace-roots-path-key.mjs [path/to/copilot.db]
21
+ * Usage: node server/migrations/0002-recent-workspace-roots-path-key.mjs [path/to/oar.db]
22
22
  */
23
23
 
24
24
  import { fileURLToPath } from 'url';
@@ -27,6 +27,7 @@ import process from 'process';
27
27
  import Database from 'better-sqlite3';
28
28
 
29
29
  import { normalizeWorkspaceRootKey } from '../services/workspace-root-path-policy.mjs';
30
+ import { cliDbPath } from '../services/database-file.mjs';
30
31
 
31
32
  /**
32
33
  * Rebuild against an already-open database handle.
@@ -97,15 +98,12 @@ export function migrate(dbPath, options = {}) {
97
98
  }
98
99
  }
99
100
 
100
- function defaultDbPath() {
101
- const here = path.dirname(fileURLToPath(import.meta.url));
102
- return path.resolve(here, '..', 'data', 'copilot.db');
103
- }
104
-
105
101
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
106
102
  if (isMain) {
107
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
108
103
  try {
104
+ // The given path, else the database in server/data (oar.db, else the
105
+ // name before 0.9.20); never a new one.
106
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
109
107
  const result = migrate(dbPath);
110
108
  console.log(`Migration 0002 on ${dbPath}`);
111
109
  if (result.applied) {
@@ -14,13 +14,14 @@
14
14
  * databases; this module exists so existing databases upgrade at startup and
15
15
  * so the migration can be run offline against a db file.
16
16
  *
17
- * Usage: node server/migrations/0003-push-subscriptions.mjs [path/to/copilot.db]
17
+ * Usage: node server/migrations/0003-push-subscriptions.mjs [path/to/oar.db]
18
18
  */
19
19
 
20
20
  import { fileURLToPath } from 'url';
21
21
  import path from 'path';
22
22
  import process from 'process';
23
23
  import Database from 'better-sqlite3';
24
+ import { cliDbPath } from '../services/database-file.mjs';
24
25
 
25
26
  export const PUSH_SUBSCRIPTIONS_SCHEMA = `
26
27
  CREATE TABLE IF NOT EXISTS push_subscriptions (
@@ -67,15 +68,12 @@ export function migrate(dbPath) {
67
68
  }
68
69
  }
69
70
 
70
- function defaultDbPath() {
71
- const here = path.dirname(fileURLToPath(import.meta.url));
72
- return path.resolve(here, '..', 'data', 'copilot.db');
73
- }
74
-
75
71
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
76
72
  if (isMain) {
77
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
78
73
  try {
74
+ // The given path, else the database in server/data (oar.db, else the
75
+ // name before 0.9.20); never a new one.
76
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
79
77
  const result = migrate(dbPath);
80
78
  console.log(`Migration 0003 on ${dbPath}`);
81
79
  console.log(result.applied ? ' created push_subscriptions' : ` skipped: ${result.reason}`);
@@ -20,7 +20,7 @@
20
20
  * Idempotent: the column is added once, the backfill only fills NULLs, and the
21
21
  * kind rewrite matches nothing once applied. Safe to run on every boot.
22
22
  *
23
- * Usage: node server/migrations/0004-steer-settle-markers.mjs [path/to/copilot.db]
23
+ * Usage: node server/migrations/0004-steer-settle-markers.mjs [path/to/oar.db]
24
24
  */
25
25
 
26
26
  import { fileURLToPath } from 'url';
@@ -29,6 +29,7 @@ import process from 'process';
29
29
  import Database from 'better-sqlite3';
30
30
 
31
31
  import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
32
+ import { cliDbPath } from '../services/database-file.mjs';
32
33
 
33
34
  function tableExists(db, name) {
34
35
  return !!db
@@ -97,15 +98,12 @@ export function migrate(dbPath) {
97
98
  }
98
99
  }
99
100
 
100
- function defaultDbPath() {
101
- const here = path.dirname(fileURLToPath(import.meta.url));
102
- return path.resolve(here, '..', 'data', 'copilot.db');
103
- }
104
-
105
101
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
106
102
  if (isMain) {
107
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
108
103
  try {
104
+ // The given path, else the database in server/data (oar.db, else the
105
+ // name before 0.9.20); never a new one.
106
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
109
107
  const result = migrate(dbPath);
110
108
  console.log(`Migration 0004 on ${dbPath}`);
111
109
  console.log(result.applied
@@ -15,13 +15,14 @@
15
15
  *
16
16
  * Additive and idempotent. Safe to run on every boot.
17
17
  *
18
- * Usage: node server/migrations/0005-remote-relays.mjs [path/to/copilot.db]
18
+ * Usage: node server/migrations/0005-remote-relays.mjs [path/to/oar.db]
19
19
  */
20
20
 
21
21
  import { fileURLToPath } from 'url';
22
22
  import path from 'path';
23
23
  import process from 'process';
24
24
  import Database from 'better-sqlite3';
25
+ import { cliDbPath } from '../services/database-file.mjs';
25
26
 
26
27
  function tableExists(db, name) {
27
28
  return !!db
@@ -68,15 +69,12 @@ export function migrate(dbPath) {
68
69
  }
69
70
  }
70
71
 
71
- function defaultDbPath() {
72
- const here = path.dirname(fileURLToPath(import.meta.url));
73
- return path.resolve(here, '..', 'data', 'copilot.db');
74
- }
75
-
76
72
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
77
73
  if (isMain) {
78
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
79
74
  try {
75
+ // The given path, else the database in server/data (oar.db, else the
76
+ // name before 0.9.20); never a new one.
77
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
80
78
  const result = migrate(dbPath);
81
79
  console.log(`Migration 0005 on ${dbPath}`);
82
80
  console.log(result.applied
@@ -11,13 +11,14 @@
11
11
  *
12
12
  * Additive and idempotent. Safe to run on every boot.
13
13
  *
14
- * Usage: node server/migrations/0006-workspace-root-settings.mjs [path/to/copilot.db]
14
+ * Usage: node server/migrations/0006-workspace-root-settings.mjs [path/to/oar.db]
15
15
  */
16
16
 
17
17
  import { fileURLToPath } from 'url';
18
18
  import path from 'path';
19
19
  import process from 'process';
20
20
  import Database from 'better-sqlite3';
21
+ import { cliDbPath } from '../services/database-file.mjs';
21
22
 
22
23
  function tableExists(db, name) {
23
24
  return !!db
@@ -38,11 +39,6 @@ export function migrateWorkspaceRootSettings(db) {
38
39
  return { applied: !existed, tableCreated: !existed };
39
40
  }
40
41
 
41
- function defaultDbPath() {
42
- const here = path.dirname(fileURLToPath(import.meta.url));
43
- return path.resolve(here, '..', 'data', 'copilot.db');
44
- }
45
-
46
42
  export function migrate(dbPath, options = {}) {
47
43
  const db = new Database(dbPath, options);
48
44
  try {
@@ -54,8 +50,10 @@ export function migrate(dbPath, options = {}) {
54
50
 
55
51
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
56
52
  if (isMain) {
57
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
58
53
  try {
54
+ // The given path, else the database in server/data (oar.db, else the
55
+ // name before 0.9.20); never a new one.
56
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
59
57
  const result = migrate(dbPath);
60
58
  console.log(`Migration 0006 on ${dbPath}`);
61
59
  console.log(result.applied ? ' workspace_root_settings table created' : ' skipped: nothing to change');
@@ -17,13 +17,14 @@
17
17
  *
18
18
  * Additive and idempotent. Safe to run on every boot.
19
19
  *
20
- * Usage: node server/migrations/0007-claude-cloud.mjs [path/to/copilot.db]
20
+ * Usage: node server/migrations/0007-claude-cloud.mjs [path/to/oar.db]
21
21
  */
22
22
 
23
23
  import { fileURLToPath } from 'url';
24
24
  import path from 'path';
25
25
  import process from 'process';
26
26
  import Database from 'better-sqlite3';
27
+ import { cliDbPath } from '../services/database-file.mjs';
27
28
 
28
29
  const COLUMNS = Object.freeze([
29
30
  Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_session_id', type: 'TEXT' }),
@@ -53,11 +54,6 @@ export function migrateClaudeCloud(db) {
53
54
  return { applied: columnsAdded > 0, columnsAdded };
54
55
  }
55
56
 
56
- function defaultDbPath() {
57
- const here = path.dirname(fileURLToPath(import.meta.url));
58
- return path.resolve(here, '..', 'data', 'copilot.db');
59
- }
60
-
61
57
  export function migrate(dbPath, options = {}) {
62
58
  const db = new Database(dbPath, options);
63
59
  try {
@@ -69,8 +65,10 @@ export function migrate(dbPath, options = {}) {
69
65
 
70
66
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
71
67
  if (isMain) {
72
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
73
68
  try {
69
+ // The given path, else the database in server/data (oar.db, else the
70
+ // name before 0.9.20); never a new one.
71
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
74
72
  const result = migrate(dbPath);
75
73
  console.log(`Migration 0007 on ${dbPath}`);
76
74
  console.log(result.applied ? ` claude cloud columns added: ${result.columnsAdded}` : ' skipped: nothing to change');
@@ -11,13 +11,14 @@
11
11
  *
12
12
  * Additive and idempotent. Safe to run on every boot.
13
13
  *
14
- * Usage: node server/migrations/0008-message-pins.mjs [path/to/copilot.db]
14
+ * Usage: node server/migrations/0008-message-pins.mjs [path/to/oar.db]
15
15
  */
16
16
 
17
17
  import { fileURLToPath } from 'url';
18
18
  import path from 'path';
19
19
  import process from 'process';
20
20
  import Database from 'better-sqlite3';
21
+ import { cliDbPath } from '../services/database-file.mjs';
21
22
 
22
23
  function tableExists(db, name) {
23
24
  return !!db
@@ -54,11 +55,6 @@ export function migrateMessagePins(db) {
54
55
  return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
55
56
  }
56
57
 
57
- function defaultDbPath() {
58
- const here = path.dirname(fileURLToPath(import.meta.url));
59
- return path.resolve(here, '..', 'data', 'copilot.db');
60
- }
61
-
62
58
  export function migrate(dbPath, options = {}) {
63
59
  const db = new Database(dbPath, options);
64
60
  try {
@@ -70,8 +66,10 @@ export function migrate(dbPath, options = {}) {
70
66
 
71
67
  const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
72
68
  if (isMain) {
73
- const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
74
69
  try {
70
+ // The given path, else the database in server/data (oar.db, else the
71
+ // name before 0.9.20); never a new one.
72
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
75
73
  const result = migrate(dbPath);
76
74
  console.log(`Migration 0008 on ${dbPath}`);
77
75
  console.log(result.applied