@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
@@ -153,6 +153,9 @@ export const REMOTE_RELAY_SESSION_SETTINGS = Object.freeze([
153
153
  'thinking_display',
154
154
  // The working directory: the session's CLI restarts there.
155
155
  'cwd',
156
+ // Plan-only: agents may set and clear it; set, a send or an
157
+ // update_session naming another mode is refused.
158
+ 'planOnly',
156
159
  ]);
157
160
  export const REMOTE_RELAY_THINKING_DISPLAYS = THINKING_DISPLAY_MODES;
158
161
  // update_session's `group` value that takes a session out of its group (JSON
@@ -333,13 +336,17 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
333
336
  + 'show the group. Agents can name an existing group when they create a session (group: an id '
334
337
  + 'or name relay_info lists, on this relay or a paired one). update_session {session, ...} sets '
335
338
  + 'another session\'s model, effort, mode, auto_compact_window, title, group (an id or name; a name '
336
- + 'not there yet makes a new group; "none" takes the session out), thinking and cwd (its CLI restarts '
337
- + 'there; not while it works) for its next turns without prompting it, and answers the settings and '
339
+ + 'not there yet makes a new group; "none" takes the session out), thinking, cwd (its CLI restarts '
340
+ + 'there; not while it works) and planOnly for its next turns without prompting it, and answers the settings and '
338
341
  + 'what changed. A session this conversation started may be changed freely; any other (the owner\'s, '
339
342
  + 'another agent\'s, the one that started you) needs the owner\'s Allow once per conversation and relay; '
340
343
  + 'your own conversation never. A group the owner made is never removed that way (it may stay empty). A mode '
341
- + 'set so lasts until the next send that names another mode or comes from a caller in another mode (a send '
342
- + 'without mode passes the caller\'s). Renaming and dissolving groups stay the owner\'s. An argument an action does not '
344
+ + 'set so stays the session\'s own: a send without mode runs in it, and a send that names a mode runs that '
345
+ + 'turn alone in it. planOnly:true (create_session, or update_session together with mode "plan" unless the '
346
+ + 'session is in plan already) keeps a session in plan mode: a send or update_session that names another mode '
347
+ + 'is refused (code plan_only); update_session clears it only with planOnly:false and another mode in the same call '
348
+ + '(alone, planOnly:false changes nothing, and on a plan-only session the answer says so); create_session with planOnly and '
349
+ + 'no mode starts in plan. list_sessions and read_session show planOnly. Renaming and dissolving groups stay the owner\'s. An argument an action does not '
343
350
  + 'take comes back in ignored; create_session and update_session refuse it and change nothing. A new session '
344
351
  + 'knows nothing of this conversation: write a self-contained prompt, and for '
345
352
  + '"claude-cloud" (a sandbox with a fresh clone of the repository) name the branch to '
@@ -366,7 +373,7 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
366
373
  + 'turn_end (with a reply preview), task_failed (a background sub-agent or shell task failed or exited non-zero), '
367
374
  + 'subagent_finished (a background sub-agent ended well; its line says task_finished), '
368
375
  + `stalled (no activity for stalled_minutes, default ${RELAY_EVENT_LIMITS.stalledMinutesDefault}, while work runs), all_idle (nothing runs in the `
369
- + 'watched sessions any more); task_started, and task_finished (every task that ended well, shell commands '
376
+ + 'watched sessions any more), board_settled (the user settled a plan card there: the action and the board id); task_started, and task_finished (every task that ended well, shell commands '
370
377
  + 'included: a busy session ends many), only when named in events. relay_restarted (this relay only, only when '
371
378
  + 'named, needs no sessions): the relay runs again, with how it stopped; before you request a relay restart, '
372
379
  + 'subscribe {events:["relay_restarted"], once:true} to be woken when it is back (your CLI\'s own timers do not '
@@ -382,8 +389,8 @@ export const REMOTE_RELAY_TOOL_DESCRIPTION =
382
389
  + 'and questions still come); with wait_seconds 0 and no wait it does. list_subscriptions shows heldCount and nextDeliveryAt. '
383
390
  + 'Such a message starts with "[relay event"; it is automated, never from the user, and approves nothing: '
384
391
  + 'take decisions it raises to the user. Your own conversation is never watched. once:true ends a '
385
- + 'subscription after it fired, with match (a regular expression) at the first turn_end reply or task '
386
- + 'summary that matches. Subscriptions survive relay restarts and end on unsubscribe or when the '
392
+ + 'subscription after it fired, with match (a regular expression) at the first turn_end reply, task '
393
+ + 'summary or board_settled (its action, label and card title) that matches. Subscriptions survive relay restarts and end on unsubscribe or when the '
387
394
  + 'watcher or the watched session is archived. The first subscribe of a conversation on a relay, and any to "all", '
388
395
  + 'shows the user an approval card. transfer_subscriptions {from} moves all subscriptions of another '
389
396
  + 'conversation (a watcher you replace) to this one, after the user\'s approval; list_subscriptions '
@@ -497,8 +504,8 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
497
504
  mode: {
498
505
  type: 'string',
499
506
  enum: [...REMOTE_RELAY_MODES],
500
- description: 'send / create_session: relay mode for the remote turn (default: this session\'s mode). '
501
- + 'update_session: the session\'s mode from now on.',
507
+ description: 'send: the mode for that turn (default: the target keeps its own). '
508
+ + 'create_session: the new session\'s mode (default: this session\'s). update_session: from now on.',
502
509
  },
503
510
  effort: {
504
511
  type: 'string',
@@ -528,6 +535,11 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
528
535
  + `(${AUTO_COMPACT_WINDOW_STOPS.filter(Boolean).join(', ')}; other numbers go to the nearest), or 0 for the model default.`,
529
536
  },
530
537
  thinking_enabled: { type: 'boolean', description: 'update_session: whether a Claude session thinks (extended thinking) on its next turns.' },
538
+ planOnly: {
539
+ type: 'boolean',
540
+ description: 'create_session / update_session: keep the session in plan mode; agents cannot switch it to another mode while set '
541
+ + '(a send or update_session naming one is refused). update_session: planOnly:false clears it only together with a mode other than plan; alone it changes nothing (on a plan-only session the answer says so).',
542
+ },
531
543
  thinking_display: {
532
544
  type: 'string',
533
545
  enum: [...THINKING_DISPLAY_MODES],
@@ -586,7 +598,7 @@ export const REMOTE_RELAY_TOOL_INPUT_SCHEMA = Object.freeze({
586
598
  },
587
599
  match: {
588
600
  type: 'string',
589
- description: 'subscribe: a regular expression (case-insensitive) a turn_end reply or a task summary must match; other events pass.',
601
+ description: 'subscribe: a regular expression (case-insensitive) a turn_end reply, a task summary or a board_settled (its action, label and card title) must match; other events pass.',
590
602
  },
591
603
  stalled_minutes: {
592
604
  type: 'integer',
@@ -685,7 +697,7 @@ export const REMOTE_RELAY_ACTION_ARGUMENTS = Object.freeze({
685
697
  read_task_output: Object.freeze(['session', 'task_id', 'lines']),
686
698
  wait: Object.freeze(['session', 'message_id', 'wait_seconds']),
687
699
  send: Object.freeze(['session', 'text', 'wait_seconds', 'model', 'mode', 'effort', 'if_busy', 'provider']),
688
- create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds']),
700
+ create_session: Object.freeze(['text', 'provider', 'model', 'cwd', 'repo', 'branch', 'mode', 'effort', 'title', 'group', 'wait_seconds', 'planOnly']),
689
701
  update_session: Object.freeze(['session', ...REMOTE_RELAY_SESSION_SETTINGS]),
690
702
  answer_question: Object.freeze(['question_id', 'answer', 'choices']),
691
703
  stop: Object.freeze(['session']),
@@ -942,6 +954,13 @@ function validateToolInput(input, { maxWaitSeconds, now, providers, sendProvider
942
954
  if (!isValidBranchName(branchText)) return invalid(`branch "${branchText.slice(0, 80)}" is not a valid branch name`);
943
955
  args.branch = branchText;
944
956
  }
957
+ // Plan-only: kept in plan mode; false (or left out) is no lock.
958
+ const planOnly = input?.planOnly;
959
+ if (planOnly !== undefined && planOnly !== null && toText(planOnly) !== '') {
960
+ const flag = readBoolean(planOnly);
961
+ if (flag === null) return invalid('planOnly must be true or false');
962
+ if (flag) args.planOnly = true;
963
+ }
945
964
  }
946
965
  } else if (action === 'update_session') {
947
966
  const settings = validateRemoteRelaySessionSettings(input);
@@ -1030,6 +1049,12 @@ export function validateRemoteRelaySessionSettings(input) {
1030
1049
  if (enabled === null) return invalid('thinking_enabled must be true or false');
1031
1050
  values.thinking_enabled = enabled;
1032
1051
  }
1052
+ const planOnly = input?.planOnly;
1053
+ if (planOnly !== undefined && planOnly !== null && toText(planOnly) !== '') {
1054
+ const flag = readBoolean(planOnly);
1055
+ if (flag === null) return invalid('planOnly must be true or false');
1056
+ values.planOnly = flag;
1057
+ }
1033
1058
  const display = toText(input?.thinking_display).toLowerCase();
1034
1059
  if (display) {
1035
1060
  if (!THINKING_DISPLAY_MODES.includes(display)) return invalid(`thinking_display must be one of: ${THINKING_DISPLAY_MODES.join(', ')}`);
@@ -1267,6 +1292,36 @@ export function formatSessionGroupList(groups, max = REMOTE_RELAY_GROUP_LIMITS.r
1267
1292
  // a dispatcher never offers one to its agent. Defined in relay-asks.mjs.
1268
1293
  export { REMOTE_RELAY_APPROVAL_SOURCE };
1269
1294
 
1295
+ // What an approval card is about: its context carries one of these
1296
+ // as `variant`, and a `summary` of at most this many characters (kind,
1297
+ // requesting session, reason; never the prompt, answer or question text). A
1298
+ // paired relay's Inbox shows them in place of the card text it never gets.
1299
+ export const REMOTE_RELAY_APPROVAL_VARIANTS = Object.freeze([
1300
+ 'create_session', 'loop_guard', 'write', 'session_settings', 'watch', 'local_sessions',
1301
+ ]);
1302
+ export const REMOTE_RELAY_APPROVAL_SUMMARY_MAX = 160;
1303
+ // Each variant's kind label, without titles: the Inbox row's head, and the
1304
+ // push body of a device whose preview preference is off.
1305
+ export const REMOTE_RELAY_APPROVAL_VARIANT_LABELS = Object.freeze({
1306
+ create_session: 'New session',
1307
+ loop_guard: 'Loop guard',
1308
+ write: 'Write',
1309
+ session_settings: 'Session settings',
1310
+ watch: 'Watch',
1311
+ local_sessions: 'Start sessions',
1312
+ });
1313
+
1314
+ // Who pushes a card a turn waits on (an approval, a blocking ask_user) when a
1315
+ // paired relay that can push reads this relay's Inbox (`?push=1`): `notice`
1316
+ // on each such pending item. `here`: this relay's own push reached a device;
1317
+ // `wait`: its push is not settled yet; `you`: the caller holds the claim and
1318
+ // pushes; `other`: another paired relay holds it. With `you`, `claimed` says
1319
+ // whether this request recorded the claim (`now`, the caller pushes) or the
1320
+ // caller held it already (`before`, e.g. after the caller restarted: no
1321
+ // second push).
1322
+ export const REMOTE_INBOX_NOTICES = Object.freeze({ here: 'here', wait: 'wait', you: 'you', other: 'other' });
1323
+ export const REMOTE_INBOX_CLAIMS = Object.freeze({ now: 'now', before: 'before' });
1324
+
1270
1325
  /** True for such a card: a formatted question, or a stored request envelope (both carry `context`). */
1271
1326
  export function isRemoteRelayApprovalQuestion(question) {
1272
1327
  const context = question && typeof question === 'object' ? question.context : null;
@@ -0,0 +1,144 @@
1
+ import { claudeBaseModelId } from './model-id.mjs';
2
+
3
+ // The token report a worker posts after a turn (POST /api/turn-token-usage),
4
+ // checked and normalised. Shared by the relay (the route and
5
+ // services/token-usage-service.mjs) and the workers that build it.
6
+ //
7
+ // A token field a provider did not report is null; a negative, non-finite
8
+ // or unsafely large one is not a count either and becomes null, and a
9
+ // fraction is cut to the integer. 0 always means the provider reported zero. `inputTokens` is fresh
10
+ // input: without the tokens read from or written to the prompt cache.
11
+
12
+ export const TURN_TOKEN_FIELDS = Object.freeze([
13
+ 'inputTokens',
14
+ 'outputTokens',
15
+ 'cacheWriteTokens',
16
+ 'cacheReadTokens',
17
+ 'reasoningTokens',
18
+ ]);
19
+
20
+ export const TURN_TOKEN_COST_SOURCES = Object.freeze(['claude-code', 'provider', 'oar-deepseek-table']);
21
+
22
+ export const TURN_TOKEN_PROVIDERS = Object.freeze(['github', 'claude', 'claude-api', 'claude-cloud', 'deepseek', 'cursor', 'grok']);
23
+
24
+ // One report names at most this many models; more is not a turn.
25
+ export const TURN_TOKEN_MAX_MODELS = 32;
26
+ const ID_MAX_LENGTH = 200;
27
+ const BILLING_PATTERN = /^byok-[a-z0-9][a-z0-9_-]{0,39}$/;
28
+
29
+ /** A provider's model id as the tables keep it: lower case, without `[1m]`. */
30
+ export function tokenUsageModel(value) {
31
+ return claudeBaseModelId(String(value ?? '').trim()).toLowerCase();
32
+ }
33
+
34
+ // Past these a value is not a count or a cost but a broken report.
35
+ const MAX_COST_USD = 1e9;
36
+
37
+ /** A token count, or null when the value is not one. */
38
+ export function tokenCount(value) {
39
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) return null;
40
+ return Math.trunc(value);
41
+ }
42
+
43
+ function costValue(value) {
44
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > MAX_COST_USD) return null;
45
+ return value;
46
+ }
47
+
48
+ /** a + b where either may be null: null only when both are. */
49
+ export function addNullable(a, b) {
50
+ if (a == null) return b == null ? null : b;
51
+ if (b == null) return a;
52
+ return a + b;
53
+ }
54
+
55
+ /** '' or `byok-<provider type>`; null for anything else. */
56
+ export function normalizeTurnTokenBilling(value) {
57
+ const billing = String(value || '').trim().toLowerCase();
58
+ if (!billing) return '';
59
+ return BILLING_PATTERN.test(billing) ? billing : null;
60
+ }
61
+
62
+ /**
63
+ * `[{ model, inputTokens, …, costUsd, costSource, costUnknown }]`, one per
64
+ * model: the same model named twice (also `x` and `x[1m]`) is summed.
65
+ */
66
+ export function normalizeTurnTokenEntries(entries) {
67
+ const byModel = new Map();
68
+ for (const entry of Array.isArray(entries) ? entries : []) {
69
+ const model = tokenUsageModel(entry?.model);
70
+ if (!model || model.length > ID_MAX_LENGTH) continue;
71
+ const next = {
72
+ model,
73
+ ...Object.fromEntries(TURN_TOKEN_FIELDS.map((field) => [field, tokenCount(entry?.[field])])),
74
+ costUsd: costValue(entry?.costUsd),
75
+ costSource: TURN_TOKEN_COST_SOURCES.includes(entry?.costSource) ? entry.costSource : null,
76
+ costUnknown: entry?.costUnknown === true,
77
+ };
78
+ const current = byModel.get(model);
79
+ if (!current) {
80
+ byModel.set(model, next);
81
+ continue;
82
+ }
83
+ // The caps hold for the sum too, not only for each entry.
84
+ for (const field of TURN_TOKEN_FIELDS) {
85
+ const sum = addNullable(current[field], next[field]);
86
+ current[field] = sum === null ? null : tokenCount(sum);
87
+ }
88
+ const costSum = addNullable(current.costUsd, next.costUsd);
89
+ current.costUsd = costSum === null ? null : costValue(costSum);
90
+ current.costSource = current.costSource || next.costSource;
91
+ current.costUnknown = current.costUnknown || next.costUnknown;
92
+ }
93
+ return [...byModel.values()];
94
+ }
95
+
96
+ function idValue(value) {
97
+ const id = String(value ?? '').trim();
98
+ return id.length > ID_MAX_LENGTH ? null : id;
99
+ }
100
+
101
+ /**
102
+ * The report a worker posts, checked: `{ ok, report }` or `{ ok: false,
103
+ * error }`. `models` carries the turn's own counts; `sessionTotals` (Claude
104
+ * cloud) the session's running totals, of which the relay books the growth.
105
+ * Who the provider and billing are is the relay's to settle; the body's are
106
+ * only stated.
107
+ */
108
+ export function normalizeTurnTokenUsageReport(body = {}) {
109
+ const conversationId = idValue(body?.conversationId);
110
+ const turnId = idValue(body?.turnId);
111
+ const runId = idValue(body?.runId);
112
+ if (!conversationId) return { ok: false, error: 'Missing conversationId' };
113
+ if (!turnId) return { ok: false, error: 'Missing turnId' };
114
+ if (runId === null) return { ok: false, error: 'Invalid runId' };
115
+ const provider = String(body?.provider || '').trim().toLowerCase();
116
+ if (provider && !TURN_TOKEN_PROVIDERS.includes(provider)) return { ok: false, error: 'Unknown provider' };
117
+ const billing = normalizeTurnTokenBilling(body?.billing);
118
+ if (billing === null) return { ok: false, error: 'Invalid billing' };
119
+ const at = body?.at == null || body?.at === '' ? null : String(body.at).trim();
120
+ if (at !== null && !Number.isFinite(Date.parse(at))) return { ok: false, error: 'Invalid at' };
121
+ const hasModels = Array.isArray(body?.models);
122
+ const hasSessionTotals = Array.isArray(body?.sessionTotals);
123
+ if (hasModels === hasSessionTotals) return { ok: false, error: 'Send either models or sessionTotals' };
124
+ const raw = hasModels ? body.models : body.sessionTotals;
125
+ if (raw.length > TURN_TOKEN_MAX_MODELS) return { ok: false, error: `More than ${TURN_TOKEN_MAX_MODELS} models` };
126
+ const entries = normalizeTurnTokenEntries(raw);
127
+ if (!entries.length) return { ok: false, error: 'Missing models' };
128
+ const nativeSessionId = idValue(body?.nativeSessionId) || null;
129
+ if (hasSessionTotals && !nativeSessionId) return { ok: false, error: 'Missing nativeSessionId' };
130
+ return {
131
+ ok: true,
132
+ report: {
133
+ conversationId,
134
+ turnId,
135
+ runId,
136
+ provider,
137
+ billing,
138
+ nativeSessionId,
139
+ responseMessageId: idValue(body?.responseMessageId) || null,
140
+ at: at === null ? null : new Date(Date.parse(at)).toISOString(),
141
+ ...(hasModels ? { models: entries } : { sessionTotals: entries }),
142
+ },
143
+ };
144
+ }
@@ -37,6 +37,10 @@ export function createHeartbeatController({
37
37
  // relay hands a Claude chat's row only to a worker of the row's billing,
38
38
  // and knows a worker's kind from what it says here and in its hello.
39
39
  workerKind = null,
40
+ // Optional: the build id of the code this worker started on
41
+ // (shared/build-id.mjs). A worker whose websocket is down still beats, so
42
+ // the relay learns the build here as well as from the hello.
43
+ buildId = null,
40
44
  // requestPulse's window: one turn transition can flip the steering
41
45
  // snapshot several times in quick succession, and each flip only needs to
42
46
  // reach the relay once.
@@ -71,6 +75,7 @@ export function createHeartbeatController({
71
75
  ...(settleFailed.length ? { settleFailed } : {}),
72
76
  ...(Array.isArray(backgroundTaskIds) ? { backgroundTasks: backgroundTaskDigest(backgroundTaskIds) } : {}),
73
77
  ...(workerKind ? { workerKind: String(workerKind) } : {}),
78
+ ...(typeof buildId === "string" && buildId.trim() ? { buildId: buildId.trim() } : {}),
74
79
  };
75
80
  const response = await api("POST", "/api/heartbeat", body);
76
81
  // Failed by the relay, or skipped because this worker has no claim on
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The heartbeat steering snapshot of a worker that runs one delivered message
3
+ * at a time and never steers into a running turn: the Cursor, Grok and Claude
4
+ * cloud workers. They send it for the release fields, which tell the relay
5
+ * whether it may stop this worker and launch a fresh one now (its recycle
6
+ * after a deploy), the same fields the Claude and Copilot SDK workers send.
7
+ *
8
+ * `supported: false` keeps the composer as it was for these kinds: it offers
9
+ * mid-turn steering only to a worker that says `supported: true`, and reads
10
+ * neither the hold nor the cancellable ids of one that does not.
11
+ *
12
+ * - `activeAttempt`: the runner's `getActiveQueueAttempt()` (`{ id, attemptId }`
13
+ * or null), the row the running turn owns.
14
+ * - `turnActive`: the runner's own word that a turn runs (`isTurnActive()`),
15
+ * for a turn whose row has no id to show.
16
+ * - `backgroundTaskCount`: live background work that ends with the worker
17
+ * (Grok's terminals).
18
+ *
19
+ * A turn holds the release ('turn'), then live background work ('background').
20
+ */
21
+ export function buildSimpleSteeringState({ activeAttempt = null, turnActive = false, backgroundTaskCount = 0 } = {}) {
22
+ const messageId = String(activeAttempt?.id || '').trim() || null;
23
+ const turn = turnActive === true || Boolean(messageId);
24
+ const tasks = Number(backgroundTaskCount);
25
+ const liveTasks = Number.isInteger(tasks) && tasks > 0 ? tasks : 0;
26
+ const releaseHold = turn ? 'turn' : (liveTasks > 0 ? 'background' : null);
27
+ return {
28
+ turnActive: turn,
29
+ canSteer: false,
30
+ holdReason: null,
31
+ messageId,
32
+ supported: false,
33
+ cancellableIds: [],
34
+ releasable: !releaseHold,
35
+ releaseHold,
36
+ backgroundTaskCount: liveTasks,
37
+ };
38
+ }
@@ -1,3 +1,5 @@
1
+ import { buildIdsDiffer } from "../build-id.mjs";
2
+
1
3
  function normalizeText(value) {
2
4
  const text = String(value || "").trim();
3
5
  return text || null;
@@ -70,6 +72,10 @@ export function createWorkerWebSocketLink({
70
72
  // every hello and ready: the relay hands a Claude chat's row only to a
71
73
  // worker of the row's billing.
72
74
  workerKind = null,
75
+ // The build id of the code this worker started on (shared/build-id.mjs),
76
+ // stated in every hello and ready next to the kind: the relay recycles a
77
+ // worker whose build differs from its own once the worker is idle.
78
+ buildId = null,
73
79
  minBackoffMs = 1000,
74
80
  maxBackoffMs = 8_000,
75
81
  jitterMs = 250,
@@ -121,6 +127,12 @@ export function createWorkerWebSocketLink({
121
127
  let lastPongAt = null;
122
128
  let lastQueueChangedAt = null;
123
129
  let lastDeliveryAt = null;
130
+ // The build id the relay named in its latest server.hello (null when it
131
+ // named none), and whether this connection has already logged that it
132
+ // differs from ours: the relay acks every worker.hello with another
133
+ // server.hello, and the log needs the news only once per connection.
134
+ let relayBuildId = null;
135
+ let buildMismatchLogged = false;
124
136
 
125
137
  function getNowMs() {
126
138
  const value = Number(now());
@@ -175,6 +187,7 @@ export function createWorkerWebSocketLink({
175
187
  sessionId: normalizeText(getSessionId()),
176
188
  pid: normalizePositiveInt(getPid()),
177
189
  ...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
190
+ ...(normalizeText(buildId) ? { buildId: normalizeText(buildId) } : {}),
178
191
  ...(held ? { ready: false } : {}),
179
192
  });
180
193
  if (sent) lastHelloSentAt = getNowMs();
@@ -203,6 +216,7 @@ export function createWorkerWebSocketLink({
203
216
  sessionId: normalizeText(getSessionId()),
204
217
  pid: normalizePositiveInt(getPid()),
205
218
  ...(normalizeText(workerKind) ? { kind: normalizeText(workerKind) } : {}),
219
+ ...(normalizeText(buildId) ? { buildId: normalizeText(buildId) } : {}),
206
220
  });
207
221
  if (sent) lastReadySentAt = getNowMs();
208
222
  return sent;
@@ -393,6 +407,7 @@ export function createWorkerWebSocketLink({
393
407
  clearReadyRefreshTimer();
394
408
  // The next connection may reach a different (restarted) relay.
395
409
  serverCapabilities = new Set();
410
+ buildMismatchLogged = false;
396
411
  }
397
412
  if (stopped || !reconnect) return true;
398
413
  // A newer socket is already under way and owns the reconnect.
@@ -440,6 +455,13 @@ export function createWorkerWebSocketLink({
440
455
  }
441
456
  if (payload?.type === "server.hello") {
442
457
  serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
458
+ relayBuildId = normalizeText(payload.buildId);
459
+ // Only a line in the log: the relay decides when to replace this
460
+ // worker, and the worker takes no action of its own on it.
461
+ if (!buildMismatchLogged && buildIdsDiffer(relayBuildId, normalizeText(buildId))) {
462
+ buildMismatchLogged = true;
463
+ dbg(`relay build ${relayBuildId}, mine ${normalizeText(buildId)}: the relay will recycle this worker when idle`);
464
+ }
443
465
  if (!serverHelloSeen) {
444
466
  serverHelloSeen = true;
445
467
  // The handshake is complete: only now does the backoff start over.
@@ -536,6 +558,7 @@ export function createWorkerWebSocketLink({
536
558
  lastPongAt,
537
559
  lastQueueChangedAt,
538
560
  lastDeliveryAt,
561
+ relayBuildId,
539
562
  };
540
563
  }
541
564
 
@@ -16,7 +16,7 @@ import fs from 'node:fs';
16
16
  export const WORKING_DIRECTORY_MISSING_CODE = 'working-directory-missing';
17
17
 
18
18
  export const WORKING_DIRECTORY_GUIDANCE =
19
- 'Restore the folder, or choose another working directory for this conversation (⋯ → Change CWD), '
19
+ 'Restore the folder, or choose another working directory for this conversation (︙ → Change CWD), '
20
20
  + 'then send the message again.';
21
21
 
22
22
  // What a failed launch says, in the words of the SDKs: Node's spawn error, the