@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.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +317 -0
- package/README.md +10 -10
- package/bin/oar.js +391 -97
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +3 -0
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +44 -6
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-view.mjs +87 -3
- package/server/public/app/api-client.js +2 -1
- package/server/public/app/bootstrap.js +55 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-view.js +17 -2
- package/server/public/app/inbox-ui.js +32 -0
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-view.mjs +4 -0
- package/server/public/app/socket-handlers.js +10 -2
- package/server/public/index.html +14 -0
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +4 -3
- package/server/repositories/message-repository.mjs +31 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/messages-routes.mjs +87 -0
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +103 -7
- package/server/server-runtime.mjs +132 -11
- package/server/services/about-service.mjs +59 -0
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +40 -5
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-scope.mjs +77 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- 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
|
+
}
|
package/shared/relay-asks.mjs
CHANGED
|
@@ -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,
|
package/shared/relay-events.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
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
|
|
342
|
-
+ '
|
|
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
|
|
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
|
|
501
|
-
+ '
|
|
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
|
|
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
|
+
}
|