@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
@@ -20,6 +20,7 @@ import {
20
20
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
21
21
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
22
22
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import { resolveBuildId } from '../../shared/build-id.mjs';
23
24
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
24
25
  import {
25
26
  createRunnerLinkBridge,
@@ -52,6 +53,10 @@ async function main() {
52
53
 
53
54
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
54
55
  || path.resolve(__dirname, '..', 'config.json');
56
+ // The code this process started on, read once from the repo root and kept
57
+ // for its whole life: the relay recycles a worker whose build differs from
58
+ // its own, and a tree that moves on under us must not change our answer.
59
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
55
60
  // Two reads of the same small file. Collapsing them to one would mean
56
61
  // re-implementing `resolveRelayServerUrl`'s env override, host
57
62
  // normalisation and port defaulting inside this worker — a live contract
@@ -129,6 +134,7 @@ async function main() {
129
134
  let heartbeatTimer = null;
130
135
  heartbeat = createHeartbeatController({
131
136
  api,
137
+ buildId,
132
138
  pollMs: HEARTBEAT_MS,
133
139
  getSessionReady: () => true,
134
140
  getHeartbeatTimer: () => heartbeatTimer,
@@ -158,6 +164,7 @@ async function main() {
158
164
  serverUrl,
159
165
  token,
160
166
  dbg,
167
+ buildId,
161
168
  getSessionReady: () => true,
162
169
  getSessionId: () => sdkSessionId,
163
170
  getPid: () => process.pid,
@@ -238,7 +245,7 @@ async function main() {
238
245
  getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
239
246
  });
240
247
 
241
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
248
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
242
249
  heartbeat.startHeartbeat();
243
250
  wsLink.start();
244
251
  }
@@ -13,10 +13,12 @@ import {
13
13
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
14
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
15
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
+ import { resolveBuildId } from '../../shared/build-id.mjs';
16
17
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
17
18
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
19
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
19
20
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
21
+ import { buildCursorSteeringState } from './cursor-worker-steering.mjs';
20
22
  import { resolveCursorSessionStoreDir } from '../services/cursor-session-root-service.mjs';
21
23
 
22
24
  // Windows console launch: the launcher names the worker log, this copies
@@ -48,6 +50,10 @@ async function main() {
48
50
 
49
51
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
50
52
  || path.resolve(__dirname, '..', 'config.json');
53
+ // The code this process started on, read once from the repo root and kept
54
+ // for its whole life: the relay recycles a worker whose build differs from
55
+ // its own, and a tree that moves on under us must not change our answer.
56
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
51
57
  const serverUrl = resolveRelayServerUrl({ configPath });
52
58
  const token = loadTokenFromConfig(configPath);
53
59
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -93,17 +99,22 @@ async function main() {
93
99
  let heartbeatTimer = null;
94
100
  const heartbeat = createHeartbeatController({
95
101
  api,
102
+ buildId,
96
103
  pollMs: HEARTBEAT_MS,
97
104
  getSessionReady: () => true,
98
105
  getHeartbeatTimer: () => heartbeatTimer,
99
106
  setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
100
107
  getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
108
+ // No steering here; the snapshot tells the relay when it may replace
109
+ // this worker: between turns (the agent handle is rebuilt on demand).
110
+ getSteeringState: () => buildCursorSteeringState(turnRunner),
101
111
  });
102
112
 
103
113
  const wsLink = createWorkerWebSocketLink({
104
114
  serverUrl,
105
115
  token,
106
116
  dbg,
117
+ buildId,
107
118
  getSessionReady: () => true,
108
119
  getSessionId: () => sdkSessionId,
109
120
  getPid: () => process.pid,
@@ -146,7 +157,7 @@ async function main() {
146
157
  getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
147
158
  });
148
159
 
149
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
160
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
150
161
  heartbeat.startHeartbeat();
151
162
  wsLink.start();
152
163
  }
@@ -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
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Cursor worker's heartbeat steering snapshot, from its turn runner
3
+ * (cursor-turn-runner.mjs). A module of its own so it can be tested: the
4
+ * worker main runs at import.
5
+ *
6
+ * Only a turn holds the release. The Cursor SDK tells the runner of no work
7
+ * that outlives a run: whether a sub-agent or a background shell can keep
8
+ * running after the run's result is not something its types say, and the
9
+ * runner sees neither of them, so there is no background count to send. The
10
+ * relay's quiet rule (10 minutes without a delivery or a turn end) covers
11
+ * what the SDK does not say.
12
+ */
13
+ import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
14
+
15
+ export function buildCursorSteeringState(turnRunner) {
16
+ return buildSimpleSteeringState({
17
+ activeAttempt: turnRunner.getActiveQueueAttempt(),
18
+ turnActive: turnRunner.isTurnActive(),
19
+ });
20
+ }
@@ -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).
@@ -235,6 +235,15 @@ export function createGrokTaskPublisher({
235
235
  stop,
236
236
  /** The ids of the live terminals, for the heartbeat's digest. */
237
237
  backgroundTaskIds: () => liveEntries().map((entry) => entry.taskId),
238
+ /**
239
+ * Terminals whose process runs and that the agent has not released, from
240
+ * the moment they start: a superset of the live ids, which wait out the
241
+ * publish grace first. What may die with the worker, so the relay must
242
+ * not replace it while any is open (the steering snapshot's count); a
243
+ * `sleep 600` started a second before a recycle is as live as one the
244
+ * panel already shows.
245
+ */
246
+ openTerminalCount: () => [...terminals.values()].filter((entry) => !entry.exited && !entry.released).length,
238
247
  /** The relay asked for the set again (heartbeat answer). */
239
248
  resync: () => publish({ resync: true }),
240
249
  /** The relay answered a heartbeat: settle notices kept for it go out. */
@@ -12,10 +12,12 @@ import {
12
12
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
13
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
14
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
+ import { resolveBuildId } from '../../shared/build-id.mjs';
15
16
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
16
17
  import { createControlPoller } from '../../shared/control-poller.mjs';
17
18
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
19
  import { createGrokTurnRunner } from './grok-turn-runner.mjs';
20
+ import { buildGrokSteeringState } from './grok-worker-steering.mjs';
19
21
  import { createGrokTaskPublisher } from './grok-background-tasks.mjs';
20
22
 
21
23
  // Windows console launch: the launcher names the worker log, this copies
@@ -47,6 +49,10 @@ async function main() {
47
49
 
48
50
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
49
51
  || path.resolve(__dirname, '..', 'config.json');
52
+ // The code this process started on, read once from the repo root and kept
53
+ // for its whole life: the relay recycles a worker whose build differs from
54
+ // its own, and a tree that moves on under us must not change our answer.
55
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
50
56
  const serverUrl = resolveRelayServerUrl({ configPath });
51
57
  const token = loadTokenFromConfig(configPath);
52
58
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -96,6 +102,7 @@ async function main() {
96
102
  let heartbeatTimer = null;
97
103
  heartbeat = createHeartbeatController({
98
104
  api,
105
+ buildId,
99
106
  pollMs: HEARTBEAT_MS,
100
107
  getSessionReady: () => true,
101
108
  getHeartbeatTimer: () => heartbeatTimer,
@@ -106,12 +113,16 @@ async function main() {
106
113
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
107
114
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
108
115
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
116
+ // No steering here; the snapshot tells the relay when it may replace
117
+ // this worker: no turn, and no open terminal (they end with the worker).
118
+ getSteeringState: () => buildGrokSteeringState(turnRunner),
109
119
  });
110
120
 
111
121
  const wsLink = createWorkerWebSocketLink({
112
122
  serverUrl,
113
123
  token,
114
124
  dbg,
125
+ buildId,
115
126
  getSessionReady: () => true,
116
127
  // A relay that just (re)started learns of the terminals from the digest
117
128
  // on the next beat; ask for it now.
@@ -171,7 +182,7 @@ async function main() {
171
182
  getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
172
183
  });
173
184
 
174
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
185
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
175
186
  heartbeat.startHeartbeat();
176
187
  wsLink.start();
177
188
  }
@@ -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) {
@@ -629,6 +667,9 @@ export function createGrokTurnRunner({
629
667
  // The terminals as background tasks: the heartbeat's digest and resync,
630
668
  // the panel's Stop, kept settle notices.
631
669
  backgroundTaskIds: () => terminalTasks?.backgroundTaskIds() || [],
670
+ // Every terminal that runs and is not released, published or not yet:
671
+ // the steering snapshot's background count (grok-worker-steering.mjs).
672
+ openTerminalCount: () => terminalTasks?.openTerminalCount?.() || 0,
632
673
  resyncBackgroundTasks: () => terminalTasks?.resync(),
633
674
  stopBackgroundTask: (taskId) => terminalTasks?.stop(taskId) || false,
634
675
  flushKeptTaskSettles: () => terminalTasks?.flushKeptSettles(),
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The Grok worker's heartbeat steering snapshot, from its turn runner
3
+ * (grok-turn-runner.mjs). A module of its own so it can be tested: the worker
4
+ * main runs at import.
5
+ *
6
+ * A turn holds the release, then any open terminal: it dies with the worker.
7
+ * Counted from the terminal's start (`openTerminalCount`), not from when it
8
+ * becomes a panel row (`backgroundTaskIds`, after a 2 s grace), so a command
9
+ * started just before a recycle sweep still holds it.
10
+ */
11
+ import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
12
+
13
+ export function buildGrokSteeringState(turnRunner) {
14
+ const open = Number(turnRunner.openTerminalCount?.()) || 0;
15
+ // The live ids are open terminals too; the larger count wins should the two
16
+ // ever disagree.
17
+ const listed = turnRunner.backgroundTaskIds?.().length || 0;
18
+ return buildSimpleSteeringState({
19
+ activeAttempt: turnRunner.getActiveQueueAttempt(),
20
+ turnActive: turnRunner.isTurnActive(),
21
+ backgroundTaskCount: Math.max(open, listed),
22
+ });
23
+ }
@@ -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