@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
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The buttons of a `plan_ready` card, one list for every provider: the Claude,
3
+ * Copilot, Cursor and Grok workers post it, the CLI extension adds
4
+ * "Approve plan" to the Copilot CLI's own list, and the relay's action route
5
+ * builds the follow-up message from the same table.
6
+ *
7
+ * - `approve_only` marks the plan approved. Its follow-up runs one turn in
8
+ * agent mode (plan mode blocks writes, and the plan doc still has to be
9
+ * committed); the session's saved mode stays plan.
10
+ * - `autopilot` / `autopilot_fleet` and `interactive` leave plan mode: the
11
+ * session goes on in the mode the follow-up runs in.
12
+ * - `exit_only` only settles the card.
13
+ *
14
+ * No follow-up carries the plan: the session wrote it, and repeating it read
15
+ * as a new instruction to carry it out.
16
+ */
17
+
18
+ export const PLAN_BOARD_APPROVE_ONLY = 'approve_only';
19
+
20
+ export const PLAN_BOARD_ACTIONS = Object.freeze([
21
+ Object.freeze({ id: 'approve_only', label: 'Approve plan', mode: 'agent' }),
22
+ Object.freeze({ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' }),
23
+ Object.freeze({ id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' }),
24
+ Object.freeze({ id: 'exit_only', label: 'Stop here', mode: 'agent' }),
25
+ ]);
26
+
27
+ /** The actions that take the session out of plan mode for good. */
28
+ export const PLAN_BOARD_LEAVES_PLAN = Object.freeze(['autopilot', 'autopilot_fleet', 'interactive']);
29
+
30
+ export const PLAN_BOARD_LABELS = Object.freeze({
31
+ approve_only: 'Approve plan',
32
+ autopilot: 'Implement in autopilot',
33
+ autopilot_fleet: 'Implement with autopilot fleet',
34
+ interactive: 'Stop here and prompt myself',
35
+ exit_only: 'Stop here',
36
+ });
37
+
38
+ const FOLLOWUP_TEXTS = Object.freeze({
39
+ approve_only: 'Plan approved by the user. Do not implement. Finish only the plan-only steps you were given, then stop.',
40
+ autopilot: 'Please implement the approved plan now.',
41
+ autopilot_fleet: 'Please implement the approved plan using autopilot_fleet where useful.',
42
+ interactive: 'I approved the plan. Let\'s proceed interactively with implementation.',
43
+ });
44
+
45
+ const FOLLOWUP_MODES = Object.freeze({
46
+ approve_only: 'agent',
47
+ autopilot: 'autopilot',
48
+ autopilot_fleet: 'autopilot',
49
+ interactive: 'agent',
50
+ });
51
+
52
+ function actionId(value) {
53
+ return String(value || '').trim().toLowerCase();
54
+ }
55
+
56
+ /** Whether `id` is one of the actions this table knows. */
57
+ export function isKnownPlanBoardAction(id) {
58
+ return Object.prototype.hasOwnProperty.call(PLAN_BOARD_LABELS, actionId(id));
59
+ }
60
+
61
+ /** The button's label; an id the table does not know is spelled out. */
62
+ export function planBoardActionLabel(id) {
63
+ const key = actionId(id);
64
+ if (Object.prototype.hasOwnProperty.call(PLAN_BOARD_LABELS, key)) return PLAN_BOARD_LABELS[key];
65
+ return key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()) || 'Select';
66
+ }
67
+
68
+ /** The message the action queues, or '' when it queues none. */
69
+ export function planBoardFollowupText(id) {
70
+ const key = actionId(id);
71
+ return Object.prototype.hasOwnProperty.call(FOLLOWUP_TEXTS, key) ? FOLLOWUP_TEXTS[key] : '';
72
+ }
73
+
74
+ /** The relay mode the follow-up runs in, or null for an action without one. */
75
+ export function planBoardFollowupMode(id) {
76
+ const key = actionId(id);
77
+ return Object.prototype.hasOwnProperty.call(FOLLOWUP_MODES, key) ? FOLLOWUP_MODES[key] : null;
78
+ }
79
+
80
+ /**
81
+ * `actions` with "Approve plan" first when it is missing. The others keep
82
+ * their order and their entries (a CLI's own list, a card stored before the
83
+ * button existed). Entries may be ids or `{ id }` objects.
84
+ */
85
+ export function withApproveOnly(actions) {
86
+ const list = Array.isArray(actions) ? actions : [];
87
+ const has = list.some((entry) => actionId(typeof entry === 'string' ? entry : entry?.id) === PLAN_BOARD_APPROVE_ONLY);
88
+ if (has) return list.slice();
89
+ return [{ ...PLAN_BOARD_ACTIONS[0] }, ...list];
90
+ }
91
+
92
+ /** Whether a card is a pending plan card, the one kind "Approve plan" is added to. */
93
+ export function isPendingPlanBoard(board) {
94
+ return String(board?.boardType || '').trim().toLowerCase() === 'plan_ready'
95
+ && (String(board?.status || 'pending').trim().toLowerCase() || 'pending') === 'pending';
96
+ }
97
+
98
+ /**
99
+ * The actions a card shows: a pending plan card gets "Approve plan" even when
100
+ * it was stored before the button existed. A settled card shows what it was
101
+ * stored with.
102
+ */
103
+ export function planBoardShownActions(board, actions = board?.actions) {
104
+ return isPendingPlanBoard(board) ? withApproveOnly(actions) : (Array.isArray(actions) ? actions : []);
105
+ }
106
+
107
+ /**
108
+ * The card's actions from the Copilot CLI's own `exit_plan_mode` arguments
109
+ * (ids or `{ id | actionId | value }` entries), in the CLI's order, each id
110
+ * once, with "Approve plan" added. Without any, the shared list. An id the
111
+ * table does not know keeps no mode: pressing it only settles the card.
112
+ */
113
+ export function planBoardActionsFromCli(rawActions) {
114
+ const ids = Array.isArray(rawActions)
115
+ ? rawActions
116
+ .map((entry) => {
117
+ if (typeof entry === 'string') return actionId(entry);
118
+ if (entry && typeof entry === 'object') return actionId(entry.id || entry.actionId || entry.value);
119
+ return '';
120
+ })
121
+ .filter(Boolean)
122
+ : [];
123
+ if (!ids.length) return PLAN_BOARD_ACTIONS.map((entry) => ({ ...entry }));
124
+ const unique = [...new Set(ids)];
125
+ return withApproveOnly(unique.map((id) => ({
126
+ id,
127
+ label: planBoardActionLabel(id),
128
+ mode: id === 'exit_only' ? 'agent' : planBoardFollowupMode(id),
129
+ })));
130
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Plan-only sessions: a conversation flagged `plan_only` stays in
3
+ * plan mode as far as agents go. A relay_tool send or update_session that
4
+ * names another mode is refused with 409 and this code; the owner's own
5
+ * controls still change the mode, and clearing the flag frees the session.
6
+ * The lock protects against mistakes, not against an agent that clears it.
7
+ */
8
+
9
+ import { planBoardFollowupMode, planBoardFollowupText } from './plan-board-actions.mjs';
10
+
11
+ export const PLAN_ONLY_ERROR_CODE = 'plan_only';
12
+
13
+ export const PLAN_ONLY_REFUSAL_TEXT = 'This session is plan-only; agents can\'t change its mode. Clear planOnly first, or ask the user.';
14
+
15
+ export const PLAN_ONLY_REFUSAL = Object.freeze({ ok: false, code: PLAN_ONLY_ERROR_CODE, error: PLAN_ONLY_REFUSAL_TEXT });
16
+
17
+ /**
18
+ * Whether naming `mode` is refused on a plan-only session: any mode but plan,
19
+ * one the relay does not know included (it must not fall back to a writing
20
+ * mode). No mode named refuses nothing.
21
+ */
22
+ export function planOnlyRefusesMode(mode) {
23
+ const text = String(mode ?? '').trim().toLowerCase();
24
+ return !!text && text !== 'plan';
25
+ }
26
+
27
+ /**
28
+ * The mode a turn cut off (a usage limit, a relay restart) goes on in. As it
29
+ * ran, except on a plan-only session: there only the "Approve plan" turn
30
+ * finishes in the agent mode it ran in (the same single turn); any other
31
+ * goes on in plan. The session's saved mode is not touched either way.
32
+ * The board does not store the message it queued, so that turn is known by
33
+ * both its exact line and its agent mode; anything else goes on in plan.
34
+ */
35
+ export function resumeRelayMode({ relayMode = '', text = '', planOnly = false } = {}) {
36
+ const stored = String(relayMode || '').trim().toLowerCase();
37
+ const mode = stored || 'agent';
38
+ if (!planOnly || mode === 'plan') return mode;
39
+ // The mode as stored: the board stores agent, never an empty one.
40
+ const approveTurn = stored === planBoardFollowupMode('approve_only')
41
+ && String(text || '').trim() === planBoardFollowupText('approve_only');
42
+ return approveTurn ? mode : 'plan';
43
+ }
@@ -35,6 +35,9 @@ export const RELAY_ASK_CLOSED_STATUSES = Object.freeze(['answered', 'dismissed']
35
35
  // Socket event the relay sends on every change of an ask (`{ ask }`) and
36
36
  // of the Inbox's count (`{ pending }`).
37
37
  export const RELAY_ASK_SOCKET_EVENT = 'relay_ask_updated';
38
+ // Socket event (no payload) the relay sends when the pending items of its
39
+ // paired relays' Inboxes changed (remote-inbox-watch-service.mjs).
40
+ export const INBOX_REMOTE_CHANGED_EVENT = 'inbox_remote_changed';
38
41
 
39
42
  export const RELAY_ASK_LIMITS = Object.freeze({
40
43
  questionMax: 500,
@@ -33,6 +33,9 @@ export const RELAY_EVENT_TYPES = Object.freeze([
33
33
  'task_failed',
34
34
  'stalled',
35
35
  'all_idle',
36
+ // The user settled a plan card (board) of the session: its action, and the
37
+ // message it queued (none for exit_only).
38
+ 'board_settled',
36
39
  ]);
37
40
 
38
41
  // Not one a subscription names in `events`: `heartbeat_minutes` turns it on.
@@ -97,6 +100,26 @@ export const RELAY_EVENT_LEGACY_DEFAULT_TYPES = Object.freeze([
97
100
  'all_idle',
98
101
  ]);
99
102
 
103
+ // The default from subagent_finished until board_settled (0.9.19): a
104
+ // subscription made without events then stored exactly this list, and takes
105
+ // today's default as well. Unlike the older one, a watcher who names exactly
106
+ // this list now gets it read as the default too (board_settled added): there
107
+ // is no type to add beside it that changes nothing.
108
+ export const RELAY_EVENT_PRE_BOARD_DEFAULT_TYPES = Object.freeze([
109
+ 'question_asked',
110
+ 'error',
111
+ 'turn_end',
112
+ 'subagent_finished',
113
+ 'task_failed',
114
+ 'stalled',
115
+ 'all_idle',
116
+ ]);
117
+
118
+ function sameTypeSet(types, reference) {
119
+ const unique = new Set((Array.isArray(types) ? types : []).map(toText).filter(Boolean));
120
+ return unique.size === reference.length && reference.every((type) => unique.has(type));
121
+ }
122
+
100
123
  /**
101
124
  * The events a stored subscription takes. A list that is exactly the old
102
125
  * default (any order, no more, no less) was the default, not a choice, and
@@ -106,14 +129,14 @@ export const RELAY_EVENT_LEGACY_DEFAULT_TYPES = Object.freeze([
106
129
  */
107
130
  export function effectiveRelayEventTypes(types) {
108
131
  const list = Array.isArray(types) ? types.map(toText).filter(Boolean) : [];
109
- return isLegacyDefaultRelayEventTypes(list) ? [...RELAY_EVENT_DEFAULT_TYPES] : list;
132
+ return isLegacyDefaultRelayEventTypes(list) || sameTypeSet(list, RELAY_EVENT_PRE_BOARD_DEFAULT_TYPES)
133
+ ? [...RELAY_EVENT_DEFAULT_TYPES]
134
+ : list;
110
135
  }
111
136
 
112
137
  /** Whether a list is exactly the old default as a set (any order, repeats ignored). */
113
138
  export function isLegacyDefaultRelayEventTypes(types) {
114
- const unique = new Set((Array.isArray(types) ? types : []).map(toText).filter(Boolean));
115
- return unique.size === RELAY_EVENT_LEGACY_DEFAULT_TYPES.length
116
- && RELAY_EVENT_LEGACY_DEFAULT_TYPES.every((type) => unique.has(type));
139
+ return sameTypeSet(types, RELAY_EVENT_LEGACY_DEFAULT_TYPES);
117
140
  }
118
141
 
119
142
  /**
@@ -403,6 +426,9 @@ export function relayEventMatchText(event) {
403
426
  if (event?.type === 'task_finished' || event?.type === 'task_failed') {
404
427
  return [detail.description, detail.summary].map(toText).filter(Boolean).join('\n');
405
428
  }
429
+ if (event?.type === 'board_settled') {
430
+ return [detail.action, detail.actionLabel, detail.title].map(toText).filter(Boolean).join('\n');
431
+ }
406
432
  return null;
407
433
  }
408
434
 
@@ -576,6 +602,14 @@ export function formatRelayEventLine(event) {
576
602
  const what = oneLine(detail.what, 80) || 'work';
577
603
  return `${head}: no activity for ${Number.isFinite(minutes) ? minutes : '?'} min while ${what} is still running`;
578
604
  }
605
+ case 'board_settled': {
606
+ const action = oneLine(detail.action, 80);
607
+ const label = oneLine(detail.actionLabel, 120) || action || 'settled';
608
+ const ids = [action ? `action ${action}` : '', oneLine(detail.boardId, 80) ? `board_id ${oneLine(detail.boardId, 80)}` : ''].filter(Boolean);
609
+ // exit_only queues nothing: the session stops there.
610
+ ids.push(oneLine(detail.queuedMessageId, 80) ? `queued message ${oneLine(detail.queuedMessageId, 80)}` : 'nothing queued');
611
+ return `${head} ${quote(detail.title || 'plan card', 200)} → ${quote(label, 120)} (${ids.join(', ')})`;
612
+ }
579
613
  default:
580
614
  return head;
581
615
  }
@@ -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
+ }