@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.
- 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 +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- 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 +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- 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 +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- 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-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- 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-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- 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 +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- 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 +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- 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-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -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
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import {
|
|
3
4
|
CLAUDE_LIVE_FLAG_SETTINGS_CODE,
|
|
4
5
|
CLAUDE_LIVE_FLAG_SETTINGS_GUIDANCE,
|
|
@@ -6,17 +7,12 @@ import {
|
|
|
6
7
|
claudeFailureGuidance,
|
|
7
8
|
} from './claude-turn-failure.mjs';
|
|
8
9
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
10
|
+
import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
|
|
9
11
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
10
12
|
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
11
13
|
import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
|
|
12
14
|
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
13
|
-
import { claudeBillingTotals, claudeSearchTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
14
|
-
|
|
15
|
-
const PLAN_BOARD_ACTIONS = [
|
|
16
|
-
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
17
|
-
{ id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
|
|
18
|
-
{ id: 'exit_only', label: 'Stop here', mode: 'agent' },
|
|
19
|
-
];
|
|
15
|
+
import { claudeBillingTotals, claudeSearchTotals, claudeTokenTotals, claudeToolRequests, claudeUsageTotals } from './claude-usage-totals.mjs';
|
|
20
16
|
|
|
21
17
|
/**
|
|
22
18
|
* The attempt-fencing echo for a row's write bodies. Every publish that names
|
|
@@ -96,6 +92,13 @@ export function createClaudeTurnPublisher({
|
|
|
96
92
|
// `billingKind` is the worker's kind.
|
|
97
93
|
billingUsageRoute = null,
|
|
98
94
|
billingKind = null,
|
|
95
|
+
// The token booking (POST /api/turn-token-usage): the provider a
|
|
96
|
+
// turn's tokens are reported under (the worker's kind: 'claude',
|
|
97
|
+
// 'claude-api', 'deepseek'), null for none. `turnTokenCost(entry, at)`
|
|
98
|
+
// prices a model's share of a turn where Claude Code's own estimate does
|
|
99
|
+
// not fit (DeepSeek: `{ costUsd, costSource }`, costUsd null when unknown).
|
|
100
|
+
turnTokenProvider = null,
|
|
101
|
+
turnTokenCost = null,
|
|
99
102
|
} = {}) {
|
|
100
103
|
/** `{ code, stableCode }` of a failure code, in the backend's namespace. */
|
|
101
104
|
function failureCodes(code) {
|
|
@@ -230,6 +233,9 @@ export function createClaudeTurnPublisher({
|
|
|
230
233
|
* Advisory: a failure here must not disturb the turn's response.
|
|
231
234
|
*/
|
|
232
235
|
async function publishPlanUsage({ message, state, sdkSessionId, runId = '', resumedSessionId = '' }) {
|
|
236
|
+
// Fire-and-forget: the token post never holds the reply (the counting
|
|
237
|
+
// itself runs at once, before the next result can arrive).
|
|
238
|
+
if (turnTokenProvider) void publishTurnTokens({ message, state, runId, resumedSessionId });
|
|
233
239
|
if (billingUsageRoute) await publishBillingUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
234
240
|
if (usageRoute) {
|
|
235
241
|
await publishBackendUsage({ message, state, sdkSessionId, runId, resumedSessionId });
|
|
@@ -252,6 +258,20 @@ export function createClaudeTurnPublisher({
|
|
|
252
258
|
});
|
|
253
259
|
}
|
|
254
260
|
|
|
261
|
+
// Three trackers below measure a turn from the CLI's results, one per
|
|
262
|
+
// booking, and mirror each other's rules: `run` / runningCounts (DeepSeek's
|
|
263
|
+
// card), `billingRun` / billingCounts (the Claude API booking) and
|
|
264
|
+
// `tokenRun` / turnCounts (the token booking). Each keys on the
|
|
265
|
+
// process (`runId`) and the CLI session id, so a `/clear` starts over;
|
|
266
|
+
// takes a fresh process's `modelUsage` growth over the last result's, but
|
|
267
|
+
// a resumed process's first result (and a result without `modelUsage`) as
|
|
268
|
+
// its turn `usage` alone, the main loop, and a result without `modelUsage`
|
|
269
|
+
// raises the baseline by that `usage`, so the next result's growth does
|
|
270
|
+
// not count it again; lets the baseline only rise, so a late result of an
|
|
271
|
+
// older turn books nothing twice; and counts a turn state once. They differ only in what they keep: hits and misses (cache
|
|
272
|
+
// writes inside) for the first two, the four token kinds apart for the
|
|
273
|
+
// third. A change to one rule belongs in all three.
|
|
274
|
+
|
|
255
275
|
// The CLI process's own token totals and web search and fetch counts per
|
|
256
276
|
// model, counted from zero for each process (`runId`) and CLI session id
|
|
257
277
|
// (a `/clear` changes it and starts the CLI's totals over). `lastSession`
|
|
@@ -475,7 +495,10 @@ export function createClaudeTurnPublisher({
|
|
|
475
495
|
}));
|
|
476
496
|
}
|
|
477
497
|
// The baseline only rises: a late result of an older turn below it
|
|
478
|
-
// must not make the next one count twice.
|
|
498
|
+
// must not make the next one count twice. A result without
|
|
499
|
+
// `modelUsage` grew the CLI's running totals by its `usage`, as `run`
|
|
500
|
+
// has it: the baseline grows by it too, so the next result's growth
|
|
501
|
+
// does not book that turn again.
|
|
479
502
|
if (hasModelUsage) {
|
|
480
503
|
for (const entry of session) {
|
|
481
504
|
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
@@ -486,6 +509,16 @@ export function createClaudeTurnPublisher({
|
|
|
486
509
|
costUsd: Math.max(last.costUsd, entry.costUsd),
|
|
487
510
|
});
|
|
488
511
|
}
|
|
512
|
+
} else {
|
|
513
|
+
for (const entry of claudeBillingTotals(null, { usage: result.usage, model })) {
|
|
514
|
+
const last = billingRun.lastSession.get(entry.model) || { hitTokens: 0, missTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
515
|
+
billingRun.lastSession.set(entry.model, {
|
|
516
|
+
hitTokens: last.hitTokens + entry.hitTokens,
|
|
517
|
+
missTokens: last.missTokens + entry.missTokens,
|
|
518
|
+
outputTokens: last.outputTokens + entry.outputTokens,
|
|
519
|
+
costUsd: last.costUsd,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
489
522
|
}
|
|
490
523
|
billingRun.results += 1;
|
|
491
524
|
}
|
|
@@ -496,6 +529,128 @@ export function createClaudeTurnPublisher({
|
|
|
496
529
|
};
|
|
497
530
|
}
|
|
498
531
|
|
|
532
|
+
// The token booking's counts, measured like billingRun above,
|
|
533
|
+
// with the four token kinds apart, then summed per turn: a turn's results
|
|
534
|
+
// (a stopped turn's, the steered rows it folded in) post the turn's total
|
|
535
|
+
// so far under its id, which the relay keeps the larger of. A worker
|
|
536
|
+
// without a process id names one for its own life, so a requeued turn on
|
|
537
|
+
// a restarted worker is a second run, not the same row.
|
|
538
|
+
const tokenRun = { key: '', results: 0, lastSession: new Map() };
|
|
539
|
+
const tokenStates = new WeakSet();
|
|
540
|
+
const turnTokens = new Map();
|
|
541
|
+
const ownRunId = randomUUID();
|
|
542
|
+
const TOKEN_FIELDS = ['inputTokens', 'cacheWriteTokens', 'cacheReadTokens', 'outputTokens'];
|
|
543
|
+
const TURN_TOKENS_KEPT = 20;
|
|
544
|
+
|
|
545
|
+
function turnCounts(state, result, { turnId, runId, sessionId, resumedSessionId, modelKey }) {
|
|
546
|
+
const model = result.model || state.responseModel;
|
|
547
|
+
const key = `${runId}\n${sessionId}`;
|
|
548
|
+
if (tokenRun.key !== key) {
|
|
549
|
+
tokenRun.key = key;
|
|
550
|
+
tokenRun.results = 0;
|
|
551
|
+
tokenRun.lastSession = new Map();
|
|
552
|
+
}
|
|
553
|
+
// Per run and turn: a turn re-run by a new CLI process (after a crash)
|
|
554
|
+
// is a second row at the relay, holding that run's tokens only.
|
|
555
|
+
const turnKey = `${runId}\n${turnId}`;
|
|
556
|
+
let turn = turnTokens.get(turnKey);
|
|
557
|
+
if (!turn) {
|
|
558
|
+
turn = new Map();
|
|
559
|
+
turnTokens.set(turnKey, turn);
|
|
560
|
+
while (turnTokens.size > TURN_TOKENS_KEPT) turnTokens.delete(turnTokens.keys().next().value);
|
|
561
|
+
}
|
|
562
|
+
if (!tokenStates.has(state)) {
|
|
563
|
+
tokenStates.add(state);
|
|
564
|
+
const at = new Date().toISOString();
|
|
565
|
+
const hasModelUsage = Boolean(result.modelUsage) && typeof result.modelUsage === 'object' && !Array.isArray(result.modelUsage);
|
|
566
|
+
const resumed = tokenRun.results === 0 && Boolean(resumedSessionId) && sessionId === resumedSessionId;
|
|
567
|
+
const session = hasModelUsage ? claudeTokenTotals(result.modelUsage, modelKey) : [];
|
|
568
|
+
const grown = !hasModelUsage || resumed
|
|
569
|
+
? claudeTokenTotals(null, { usage: result.usage, model, ...modelKey })
|
|
570
|
+
: session.map((entry) => {
|
|
571
|
+
const last = tokenRun.lastSession.get(entry.model);
|
|
572
|
+
const delta = { model: entry.model, costUnknown: entry.costUnknown };
|
|
573
|
+
for (const field of TOKEN_FIELDS) delta[field] = Math.max(0, entry[field] - (last?.[field] || 0));
|
|
574
|
+
delta.costUsd = entry.costUsd === null ? null : Math.max(0, entry.costUsd - (last?.costUsd || 0));
|
|
575
|
+
return delta;
|
|
576
|
+
});
|
|
577
|
+
for (const entry of grown) {
|
|
578
|
+
const priced = typeof turnTokenCost === 'function' ? turnTokenCost(entry, at) : null;
|
|
579
|
+
const costUsd = priced ? priced.costUsd : entry.costUsd;
|
|
580
|
+
const costSource = priced ? priced.costSource : (entry.costUsd === null ? null : 'claude-code');
|
|
581
|
+
const current = turn.get(entry.model) || {
|
|
582
|
+
model: entry.model, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUsd: null, costSource: null, costUnknown: false,
|
|
583
|
+
};
|
|
584
|
+
for (const field of TOKEN_FIELDS) current[field] += entry[field];
|
|
585
|
+
if (costUsd !== null && costUsd !== undefined) current.costUsd = (current.costUsd ?? 0) + costUsd;
|
|
586
|
+
current.costSource = current.costSource || costSource;
|
|
587
|
+
// A backend's own price wins over Claude Code's word on it (DeepSeek's
|
|
588
|
+
// models are unknown to Claude Code's table, yet OAR prices them).
|
|
589
|
+
const unknown = priced ? priced.costUsd === null || priced.costUsd === undefined : (entry.costUnknown || costUsd === null || costUsd === undefined);
|
|
590
|
+
current.costUnknown = current.costUnknown || unknown;
|
|
591
|
+
turn.set(entry.model, current);
|
|
592
|
+
}
|
|
593
|
+
// The baseline only rises: a late result of an older turn below it
|
|
594
|
+
// must not make the next one count twice. A result without
|
|
595
|
+
// `modelUsage` grew the CLI's running totals by its `usage`, as `run`
|
|
596
|
+
// has it: the baseline grows by it too.
|
|
597
|
+
for (const entry of session) {
|
|
598
|
+
const last = tokenRun.lastSession.get(entry.model) || {};
|
|
599
|
+
const next = { costUsd: Math.max(last.costUsd || 0, entry.costUsd || 0) };
|
|
600
|
+
for (const field of TOKEN_FIELDS) next[field] = Math.max(last[field] || 0, entry[field]);
|
|
601
|
+
tokenRun.lastSession.set(entry.model, next);
|
|
602
|
+
}
|
|
603
|
+
if (!hasModelUsage) {
|
|
604
|
+
for (const entry of grown) {
|
|
605
|
+
const last = tokenRun.lastSession.get(entry.model) || {};
|
|
606
|
+
const next = { costUsd: last.costUsd || 0 };
|
|
607
|
+
for (const field of TOKEN_FIELDS) next[field] = (last[field] || 0) + entry[field];
|
|
608
|
+
tokenRun.lastSession.set(entry.model, next);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
tokenRun.results += 1;
|
|
612
|
+
}
|
|
613
|
+
return [...turn.values()]
|
|
614
|
+
.filter((entry) => TOKEN_FIELDS.some((field) => entry[field] > 0) || entry.costUsd > 0)
|
|
615
|
+
.map((entry) => ({ ...entry }));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Ship the turn's tokens per model to the token booking: the
|
|
620
|
+
* turn's total so far, under the turn's id and the process's run id.
|
|
621
|
+
* Reasoning is not reported apart by Claude, so it is left out (NULL).
|
|
622
|
+
* Advisory and fire-and-forget: it never throws and nothing waits on it.
|
|
623
|
+
*/
|
|
624
|
+
async function publishTurnTokens({ message, state, runId = '', resumedSessionId = '' }) {
|
|
625
|
+
try {
|
|
626
|
+
const result = state.result;
|
|
627
|
+
const turnId = String(message?.id || '').trim();
|
|
628
|
+
if (!turnId || !result) return;
|
|
629
|
+
const sessionId = String(result.sessionId || '').trim();
|
|
630
|
+
const modelKey = typeof usageModelKey === 'function' ? { modelKey: usageModelKey } : {};
|
|
631
|
+
const run = String(runId || '') || ownRunId;
|
|
632
|
+
const models = turnCounts(state, result, {
|
|
633
|
+
turnId,
|
|
634
|
+
runId: run,
|
|
635
|
+
sessionId,
|
|
636
|
+
resumedSessionId: String(resumedSessionId || '').trim(),
|
|
637
|
+
modelKey,
|
|
638
|
+
});
|
|
639
|
+
if (!models.length) return;
|
|
640
|
+
await api('POST', '/api/turn-token-usage', {
|
|
641
|
+
conversationId: message.conversationId,
|
|
642
|
+
turnId,
|
|
643
|
+
runId: run,
|
|
644
|
+
provider: turnTokenProvider,
|
|
645
|
+
...(sessionId ? { nativeSessionId: sessionId } : {}),
|
|
646
|
+
at: new Date().toISOString(),
|
|
647
|
+
models,
|
|
648
|
+
});
|
|
649
|
+
} catch (error) {
|
|
650
|
+
dbg('turn token publish failed', error?.message || String(error));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
499
654
|
/**
|
|
500
655
|
* Ship the turn's counts to the Claude API booking: the process's own
|
|
501
656
|
* running totals (`runCounts`) and the CLI's session totals
|
|
@@ -159,3 +159,61 @@ export function claudeBillingTotals(modelUsage, { usage = null, model = '' } = {
|
|
|
159
159
|
}
|
|
160
160
|
return [...totals.values()].filter((entry) => entry.hitTokens + entry.missTokens + entry.outputTokens > 0 || entry.costUsd > 0);
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The CLI's totals per model with the four token kinds apart, for the token
|
|
165
|
+
* booking (POST /api/turn-token-usage): `[{ model, inputTokens,
|
|
166
|
+
* cacheWriteTokens, cacheReadTokens, outputTokens, costUsd, costUnknown }]`.
|
|
167
|
+
* `inputTokens` is fresh input (Anthropic counts cache reads and writes
|
|
168
|
+
* apart). Read like claudeBillingTotals: `modelUsage` (running totals, with
|
|
169
|
+
* Claude Code's cost estimate) or `usage` alone under the turn's model (no
|
|
170
|
+
* cost: `costUsd` null). `modelKey` maps the CLI's model names to the
|
|
171
|
+
* relay's, as in claudeUsageTotals; the `[1m]` tier is dropped. Models with
|
|
172
|
+
* no tokens and no cost are left out.
|
|
173
|
+
*/
|
|
174
|
+
export function claudeTokenTotals(modelUsage, {
|
|
175
|
+
usage = null,
|
|
176
|
+
model = '',
|
|
177
|
+
modelKey = (value) => String(value ?? '').trim(),
|
|
178
|
+
} = {}) {
|
|
179
|
+
const totals = new Map();
|
|
180
|
+
const add = (name, entry) => {
|
|
181
|
+
const key = String(modelKey(name) ?? '').trim().toLowerCase().replace(/\[1m\]$/, '');
|
|
182
|
+
if (!key) return;
|
|
183
|
+
const current = totals.get(key) || {
|
|
184
|
+
model: key, inputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, outputTokens: 0, costUsd: null, costUnknown: false,
|
|
185
|
+
};
|
|
186
|
+
current.inputTokens += entry.inputTokens;
|
|
187
|
+
current.cacheWriteTokens += entry.cacheWriteTokens;
|
|
188
|
+
current.cacheReadTokens += entry.cacheReadTokens;
|
|
189
|
+
current.outputTokens += entry.outputTokens;
|
|
190
|
+
if (entry.costUsd !== null) current.costUsd = (current.costUsd ?? 0) + entry.costUsd;
|
|
191
|
+
current.costUnknown = current.costUnknown || entry.costUnknown;
|
|
192
|
+
totals.set(key, current);
|
|
193
|
+
};
|
|
194
|
+
if (modelUsage && typeof modelUsage === 'object' && !Array.isArray(modelUsage)) {
|
|
195
|
+
for (const [name, entry] of Object.entries(modelUsage)) {
|
|
196
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
197
|
+
add(name, {
|
|
198
|
+
inputTokens: count(entry.inputTokens),
|
|
199
|
+
cacheWriteTokens: count(entry.cacheCreationInputTokens),
|
|
200
|
+
cacheReadTokens: count(entry.cacheReadInputTokens),
|
|
201
|
+
outputTokens: count(entry.outputTokens),
|
|
202
|
+
costUsd: costOf(entry.costUSD),
|
|
203
|
+
costUnknown: entry.costBasis === 'unknown',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} else if (usage && typeof usage === 'object') {
|
|
207
|
+
add(model, {
|
|
208
|
+
inputTokens: count(usage.input_tokens),
|
|
209
|
+
cacheWriteTokens: count(usage.cache_creation_input_tokens),
|
|
210
|
+
cacheReadTokens: count(usage.cache_read_input_tokens),
|
|
211
|
+
outputTokens: count(usage.output_tokens),
|
|
212
|
+
costUsd: null,
|
|
213
|
+
costUnknown: false,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return [...totals.values()].filter((entry) => (
|
|
217
|
+
entry.inputTokens + entry.cacheWriteTokens + entry.cacheReadTokens + entry.outputTokens > 0 || entry.costUsd > 0
|
|
218
|
+
));
|
|
219
|
+
}
|
|
@@ -14,16 +14,13 @@
|
|
|
14
14
|
// shape of the final prose. Both paths are wired — the hook is primary, the
|
|
15
15
|
// text heuristic is the fallback — which is the Claude worker's arrangement.
|
|
16
16
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
17
|
+
import { PLAN_BOARD_ACTIONS } from '../../shared/plan-board-actions.mjs';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* The one list every worker posts (shared/plan-board-actions.mjs): `exit_only`
|
|
21
|
+
* dismisses; the others queue a follow-up user message in the mapped mode.
|
|
21
22
|
*/
|
|
22
|
-
export
|
|
23
|
-
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
24
|
-
{ id: 'interactive', label: 'Stop here and prompt myself', mode: 'agent' },
|
|
25
|
-
{ id: 'exit_only', label: 'Stop here', mode: 'agent' },
|
|
26
|
-
];
|
|
23
|
+
export { PLAN_BOARD_ACTIONS };
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
26
|
* How many list-ish lines a final message needs before it is treated as a plan.
|
|
@@ -174,7 +174,7 @@ export function formatSubagentStats(data) {
|
|
|
174
174
|
* figures keep the FIRST call's time-to-first-token (that is the number a user
|
|
175
175
|
* experiences as "did it start answering").
|
|
176
176
|
*/
|
|
177
|
-
function createUsageAccumulator() {
|
|
177
|
+
function createUsageAccumulator({ debugTokenUsage = null } = {}) {
|
|
178
178
|
const totals = {
|
|
179
179
|
inputTokens: 0,
|
|
180
180
|
outputTokens: 0,
|
|
@@ -195,8 +195,61 @@ function createUsageAccumulator() {
|
|
|
195
195
|
isByok: null,
|
|
196
196
|
quotaSnapshots: null,
|
|
197
197
|
};
|
|
198
|
+
// The token booking's view (POST /api/turn-token-usage): the same calls,
|
|
199
|
+
// per model, the four token kinds apart. A field no call of the model
|
|
200
|
+
// carried stays null, so "not reported" is not booked as 0.
|
|
201
|
+
const byModel = new Map();
|
|
202
|
+
let compactionCalls = 0;
|
|
203
|
+
// A compaction's call as `session.compaction_complete` reports it, kept
|
|
204
|
+
// apart until the snapshot: it is booked only if no usage event of the
|
|
205
|
+
// compaction class came in the turn, before or after it.
|
|
206
|
+
const compactionTokens = new Map();
|
|
207
|
+
function addTokens(model, data, into = byModel) {
|
|
208
|
+
const key = String(model || '').trim();
|
|
209
|
+
const bucket = into.get(key) || {
|
|
210
|
+
model: key, inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, reasoningTokens: null,
|
|
211
|
+
};
|
|
212
|
+
const count = (value) => {
|
|
213
|
+
if (value === null || value === undefined || value === '') return null;
|
|
214
|
+
const n = Number(value);
|
|
215
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
216
|
+
};
|
|
217
|
+
const cacheRead = count(data.cacheReadTokens);
|
|
218
|
+
const cacheWrite = count(data.cacheWriteTokens);
|
|
219
|
+
const prompt = count(data.inputTokens);
|
|
220
|
+
// Fresh input is the prompt count less the cache reads and writes, per
|
|
221
|
+
// call, floored at 0. Inferred, not verified: the bundled runtime reads
|
|
222
|
+
// OpenAI-style `prompt_tokens` with `prompt_tokens_details.cached_tokens`
|
|
223
|
+
// (and cache creation), where the prompt count includes the cache. A
|
|
224
|
+
// real-provider check at the merge gate confirms it, reading the raw
|
|
225
|
+
// counts that OAR_DEBUG_TOKEN_USAGE=1 logs.
|
|
226
|
+
const fresh = prompt === null ? null : Math.max(0, prompt - (cacheRead || 0) - (cacheWrite || 0));
|
|
227
|
+
for (const [field, value] of [
|
|
228
|
+
['inputTokens', fresh],
|
|
229
|
+
['outputTokens', count(data.outputTokens)],
|
|
230
|
+
['cacheReadTokens', cacheRead],
|
|
231
|
+
['cacheWriteTokens', cacheWrite],
|
|
232
|
+
['reasoningTokens', count(data.reasoningTokens)],
|
|
233
|
+
]) {
|
|
234
|
+
if (value !== null) bucket[field] = (bucket[field] ?? 0) + value;
|
|
235
|
+
}
|
|
236
|
+
into.set(key, bucket);
|
|
237
|
+
}
|
|
198
238
|
function add(data, { allowModel = true } = {}) {
|
|
199
239
|
if (!data || typeof data !== 'object') return;
|
|
240
|
+
if (typeof debugTokenUsage === 'function') {
|
|
241
|
+
debugTokenUsage({
|
|
242
|
+
model: data.model ?? null,
|
|
243
|
+
interactionType: data.interactionType ?? null,
|
|
244
|
+
inputTokens: data.inputTokens ?? null,
|
|
245
|
+
cacheReadTokens: data.cacheReadTokens ?? null,
|
|
246
|
+
cacheWriteTokens: data.cacheWriteTokens ?? null,
|
|
247
|
+
outputTokens: data.outputTokens ?? null,
|
|
248
|
+
reasoningTokens: data.reasoningTokens ?? null,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
addTokens(data.model, data);
|
|
252
|
+
if (String(data.interactionType || '').trim() === 'conversation-compaction') compactionCalls += 1;
|
|
200
253
|
totals.modelCalls += 1;
|
|
201
254
|
if (!allowModel) totals.subagentModelCalls += 1;
|
|
202
255
|
for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'cost']) {
|
|
@@ -222,13 +275,38 @@ function createUsageAccumulator() {
|
|
|
222
275
|
}
|
|
223
276
|
return {
|
|
224
277
|
add,
|
|
278
|
+
/**
|
|
279
|
+
* A compaction's own model call, from `session.compaction_complete`'s
|
|
280
|
+
* `compactionTokensUsed`: booked unless the runtime reports it, before
|
|
281
|
+
* or after, as an `assistant.usage` of the compaction class.
|
|
282
|
+
*/
|
|
283
|
+
addCompaction(tokensUsed, fallbackModel = '') {
|
|
284
|
+
if (!tokensUsed || typeof tokensUsed !== 'object') return;
|
|
285
|
+
addTokens(tokensUsed.model || fallbackModel, tokensUsed, compactionTokens);
|
|
286
|
+
},
|
|
225
287
|
setQuotaSnapshots(snapshots) {
|
|
226
288
|
if (snapshots && typeof snapshots === 'object' && Object.keys(snapshots).length) {
|
|
227
289
|
totals.quotaSnapshots = snapshots;
|
|
228
290
|
}
|
|
229
291
|
},
|
|
230
292
|
snapshot() {
|
|
231
|
-
|
|
293
|
+
// A call without a model id is the turn's main model's.
|
|
294
|
+
const models = [];
|
|
295
|
+
const buckets = [...byModel.values(), ...(compactionCalls > 0 ? [] : compactionTokens.values())];
|
|
296
|
+
for (const bucket of buckets) {
|
|
297
|
+
const model = bucket.model || totals.model;
|
|
298
|
+
const same = models.find((entry) => entry.model === model);
|
|
299
|
+
if (!same) {
|
|
300
|
+
models.push({ ...bucket, model });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
for (const field of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
|
|
304
|
+
if (bucket[field] !== null) same[field] = (same[field] ?? 0) + bucket[field];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const tokenModels = models.filter((entry) => entry.model);
|
|
308
|
+
if (!totals.modelCalls && !totals.quotaSnapshots && !tokenModels.length) return null;
|
|
309
|
+
return { ...totals, models: tokenModels };
|
|
232
310
|
},
|
|
233
311
|
};
|
|
234
312
|
}
|
|
@@ -242,6 +320,9 @@ export function createCopilotEventNormalizer({
|
|
|
242
320
|
// The runner's answer to "is this agent id a background agent?" — shared
|
|
243
321
|
// across turns, unlike this normalizer's own `backgroundRuns`.
|
|
244
322
|
isBackgroundAgent = () => false,
|
|
323
|
+
// Called with the raw token counts of every model call (no ids), when the
|
|
324
|
+
// worker runs with OAR_DEBUG_TOKEN_USAGE=1.
|
|
325
|
+
debugTokenUsage = null,
|
|
245
326
|
} = {}) {
|
|
246
327
|
let sessionModel = '';
|
|
247
328
|
let sessionId = '';
|
|
@@ -250,7 +331,7 @@ export function createCopilotEventNormalizer({
|
|
|
250
331
|
let terminalEmitted = false;
|
|
251
332
|
let contextUsage = null;
|
|
252
333
|
let interactionId = '';
|
|
253
|
-
const usage = createUsageAccumulator();
|
|
334
|
+
const usage = createUsageAccumulator({ debugTokenUsage });
|
|
254
335
|
|
|
255
336
|
// Assistant text is assembled per `messageId`. A tool-using request produces
|
|
256
337
|
// several assistant messages (the first typically empty with `toolRequests`,
|
|
@@ -866,6 +947,8 @@ export function createCopilotEventNormalizer({
|
|
|
866
947
|
return [activityAction('Compacting the conversation context…')];
|
|
867
948
|
}
|
|
868
949
|
case 'session.compaction_complete': {
|
|
950
|
+
// Its model call costs tokens too (booked with the turn it ran in).
|
|
951
|
+
usage.addCompaction(data.compactionTokensUsed, data.model || sessionModel);
|
|
869
952
|
if (data.success === false) {
|
|
870
953
|
const reason = String(data.error || '').trim();
|
|
871
954
|
return [activityAction(truncate(reason ? `Context compaction failed: ${reason}` : 'Context compaction failed'))];
|
|
@@ -523,6 +523,14 @@ export function createCopilotSdkSessionRunner({
|
|
|
523
523
|
// The in-flight ingest POST, exposed only as a test seam. Never awaited by
|
|
524
524
|
// the turn path — that is the entire point of it.
|
|
525
525
|
let usagePostChain = Promise.resolve();
|
|
526
|
+
// The token booking's post (POST /api/turn-token-usage), a chain and a test
|
|
527
|
+
// seam like `usagePostChain`. Its run is the row's attempt (a row
|
|
528
|
+
// delivered again is another run of the turn); a row without one (an older
|
|
529
|
+
// relay) is booked under this process's own id, so a turn requeued onto a
|
|
530
|
+
// restarted worker is a second run of it, not the same row.
|
|
531
|
+
let tokenPostChain = Promise.resolve();
|
|
532
|
+
const postedTurnTokens = new WeakSet();
|
|
533
|
+
const tokenRunId = randomUUID();
|
|
526
534
|
// The last catalog content this worker POSTed to `/api/models/snapshot`,
|
|
527
535
|
// as a serialized signature. Identity of CONTENT, not of session: an idle
|
|
528
536
|
// shutdown and resume onto the same catalog must not re-publish it.
|
|
@@ -701,6 +709,12 @@ export function createCopilotSdkSessionRunner({
|
|
|
701
709
|
// card), so they hold the stall watchdog and the idle shutdown too — but
|
|
702
710
|
// not steering: nobody is answering a card here.
|
|
703
711
|
let pendingRelayToolCalls = 0;
|
|
712
|
+
// Deliveries `handlePendingPayload` is still working on. Most become (or
|
|
713
|
+
// steer into) `activeTurn` at once, but a steer that waits on the turn's
|
|
714
|
+
// send, its row or the relay context can find the turn gone and open its
|
|
715
|
+
// own afterwards: in that gap no turn exists, and the worker must still
|
|
716
|
+
// not read as replaceable (`releaseHold`).
|
|
717
|
+
let admittingDeliveries = 0;
|
|
704
718
|
// `relay_tool` is registered only while this relay has paired remotes.
|
|
705
719
|
// Asked once when the worker starts; a session build waits for that first
|
|
706
720
|
// answer (it is async anyway), and every runtime stop asks again so the
|
|
@@ -1330,6 +1344,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1330
1344
|
conversationId: message.conversationId,
|
|
1331
1345
|
sdkSessionId,
|
|
1332
1346
|
messageId: message.id,
|
|
1347
|
+
attemptId: message.attemptId || null,
|
|
1333
1348
|
model: result?.model || appliedModel() || defaultModel || '',
|
|
1334
1349
|
usage,
|
|
1335
1350
|
contextUsage,
|
|
@@ -1352,6 +1367,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1352
1367
|
hasQuotaSnapshots: !!usage?.quotaSnapshots,
|
|
1353
1368
|
cfiOverage: usage?.quotaSnapshots?.cfi_overage ?? null,
|
|
1354
1369
|
}));
|
|
1370
|
+
return lastTurnUsage;
|
|
1355
1371
|
}
|
|
1356
1372
|
|
|
1357
1373
|
/**
|
|
@@ -1372,9 +1388,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
1372
1388
|
* row still open — for as long as the socket stayed up. The result is unused
|
|
1373
1389
|
* and the failure is already swallowed, so there was nothing to wait for.
|
|
1374
1390
|
*
|
|
1375
|
-
* BYOK sessions do not post
|
|
1391
|
+
* BYOK sessions do not post here: they spend the user's own OpenAI key
|
|
1376
1392
|
* rather than Copilot quota, and their usage events report `cost: 0`, so
|
|
1377
|
-
* their numbers would only mislead on a card about the Copilot plan.
|
|
1393
|
+
* their numbers would only mislead on a card about the Copilot plan. Their
|
|
1394
|
+
* tokens still go to the token booking (postTurnTokens).
|
|
1378
1395
|
*/
|
|
1379
1396
|
function postTurnUsage() {
|
|
1380
1397
|
if (byokProvider || !lastTurnUsage || lastTurnUsage === postedTurnUsage) return;
|
|
@@ -1385,6 +1402,39 @@ export function createCopilotSdkSessionRunner({
|
|
|
1385
1402
|
.catch((error) => { dbg('usage ingest failed', error?.message || String(error)); });
|
|
1386
1403
|
}
|
|
1387
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* Report a turn's own captured tokens per model to the token booking,
|
|
1407
|
+
* from that turn's capture: a continuation that captured its
|
|
1408
|
+
* usage meanwhile cannot take the turn's place. Fire-and-forget like the plan usage, but BYOK sessions post
|
|
1409
|
+
* too, under their own billing (`byok-<provider type>`), which the relay
|
|
1410
|
+
* keeps apart from the plan's. No cost: the plan bills in AI units, not
|
|
1411
|
+
* dollars, and an own key's calls report 0.
|
|
1412
|
+
*/
|
|
1413
|
+
function postTurnTokens(capture) {
|
|
1414
|
+
if (!capture || postedTurnTokens.has(capture)) return;
|
|
1415
|
+
postedTurnTokens.add(capture);
|
|
1416
|
+
const models = (capture.usage?.models || []).map((entry) => ({
|
|
1417
|
+
model: entry.model,
|
|
1418
|
+
inputTokens: entry.inputTokens,
|
|
1419
|
+
outputTokens: entry.outputTokens,
|
|
1420
|
+
cacheWriteTokens: entry.cacheWriteTokens,
|
|
1421
|
+
cacheReadTokens: entry.cacheReadTokens,
|
|
1422
|
+
reasoningTokens: entry.reasoningTokens,
|
|
1423
|
+
}));
|
|
1424
|
+
if (!models.length || !capture.messageId) return;
|
|
1425
|
+
const billing = byokProvider ? `byok-${String(byokProvider.type || 'openai').trim().toLowerCase()}` : '';
|
|
1426
|
+
tokenPostChain = api('POST', '/api/turn-token-usage', {
|
|
1427
|
+
conversationId: capture.conversationId,
|
|
1428
|
+
turnId: capture.messageId,
|
|
1429
|
+
runId: capture.attemptId || tokenRunId,
|
|
1430
|
+
provider: 'github',
|
|
1431
|
+
billing,
|
|
1432
|
+
...(sdkSessionId ? { nativeSessionId: sdkSessionId } : {}),
|
|
1433
|
+
at: capture.capturedAt,
|
|
1434
|
+
models,
|
|
1435
|
+
}).catch((error) => { dbg('turn token ingest failed', error?.message || String(error)); });
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1388
1438
|
/**
|
|
1389
1439
|
* Publish the session's model catalog to `/api/models/snapshot` (Phase 5A:
|
|
1390
1440
|
* with the extension retired, worker snapshots and the server-side discovery
|
|
@@ -3161,7 +3211,14 @@ export function createCopilotSdkSessionRunner({
|
|
|
3161
3211
|
model: '',
|
|
3162
3212
|
// The runner's cross-turn knowledge of background agents rides in, so a
|
|
3163
3213
|
// later turn's normalizer opens no lane for an agent spawned earlier.
|
|
3164
|
-
normalizer: createNormalizerImpl({
|
|
3214
|
+
normalizer: createNormalizerImpl({
|
|
3215
|
+
isBackgroundAgent: (agentId) => agentDetails.has(agentId),
|
|
3216
|
+
// The raw counts of every model call, for checking the token rule
|
|
3217
|
+
// against a real provider (counts only).
|
|
3218
|
+
...(String(env?.OAR_DEBUG_TOKEN_USAGE || '').trim() === '1'
|
|
3219
|
+
? { debugTokenUsage: (fields) => dbg('token usage', JSON.stringify(fields)) }
|
|
3220
|
+
: {}),
|
|
3221
|
+
}),
|
|
3165
3222
|
result: null,
|
|
3166
3223
|
failure: null,
|
|
3167
3224
|
settled: false,
|
|
@@ -3730,7 +3787,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
3730
3787
|
const failure = turn.failure;
|
|
3731
3788
|
// Capture only. The POST fires from driveTurn's `finally`, after the rows
|
|
3732
3789
|
// have been published.
|
|
3733
|
-
captureTurnUsage(turn.primaryEntry.message, result);
|
|
3790
|
+
turn.usageCapture = captureTurnUsage(turn.primaryEntry.message, result) || null;
|
|
3734
3791
|
|
|
3735
3792
|
const pending = turn.entries.filter((entry) => !entry.settled && entry.turn === turn);
|
|
3736
3793
|
if (!pending.length) return;
|
|
@@ -4166,6 +4223,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
4166
4223
|
syncDeliveryReadiness();
|
|
4167
4224
|
// The ingest is advisory, never awaited, and must never be able to
|
|
4168
4225
|
// delay a reply that is already written.
|
|
4226
|
+
postTurnTokens(turn.usageCapture);
|
|
4169
4227
|
postTurnUsage();
|
|
4170
4228
|
}
|
|
4171
4229
|
}
|
|
@@ -4687,7 +4745,62 @@ export function createCopilotSdkSessionRunner({
|
|
|
4687
4745
|
const canSteer = canAcceptSteering();
|
|
4688
4746
|
const holdReason = turnActive && !canSteer ? steeringHoldReason(turn) : null;
|
|
4689
4747
|
const messageId = String(turn?.currentOwner?.message?.id || turn?.message?.id || '').trim() || null;
|
|
4690
|
-
|
|
4748
|
+
// `releasable`: the relay may stop this worker and launch a fresh one now
|
|
4749
|
+
// (its recycle after a deploy), the same fields the Claude worker sends.
|
|
4750
|
+
const hold = releaseHold();
|
|
4751
|
+
return {
|
|
4752
|
+
turnActive,
|
|
4753
|
+
canSteer,
|
|
4754
|
+
holdReason,
|
|
4755
|
+
messageId,
|
|
4756
|
+
supported: true,
|
|
4757
|
+
cancellableIds: cancellableIds(),
|
|
4758
|
+
releasable: !hold,
|
|
4759
|
+
releaseHold: hold || null,
|
|
4760
|
+
// As the task panel counts them: with the registry it is the truth, and
|
|
4761
|
+
// the event-scraped shell set would count its shells a second time.
|
|
4762
|
+
backgroundTaskCount: sessionRpc.tasks ? backgroundTasks.size : backgroundShells.size(),
|
|
4763
|
+
// The runtime process (the CLI child) is up: a client or a session
|
|
4764
|
+
// handle exists until `stopRuntime` clears both. The relay's recycle
|
|
4765
|
+
// replaces a stale worker only once it is releasable AND this is false;
|
|
4766
|
+
// releasable with the runtime up is the idle window, left to the idle
|
|
4767
|
+
// shutdown.
|
|
4768
|
+
cliAlive: Boolean(client || session),
|
|
4769
|
+
};
|
|
4770
|
+
}
|
|
4771
|
+
|
|
4772
|
+
/**
|
|
4773
|
+
* What keeps this worker from being replaced now ('' when nothing does):
|
|
4774
|
+
* the idle shutdown's gates (`evaluateLifecycle`) and the runtime tree's
|
|
4775
|
+
* busy check, named with the relay's hold words. Stopping the worker ends
|
|
4776
|
+
* the runtime, and with it the turn, the card a human is answering, a
|
|
4777
|
+
* compaction, and every background shell and agent.
|
|
4778
|
+
*
|
|
4779
|
+
* - 'question': a question card or an ask-mode approval is open;
|
|
4780
|
+
* - 'compaction': the runtime is compacting the session;
|
|
4781
|
+
* - 'turn': a turn is running or still publishing its rows, a `relay_tool`
|
|
4782
|
+
* call is out, an image row is on the relay's image routes, or a
|
|
4783
|
+
* delivery is on its way in;
|
|
4784
|
+
* - 'background': a background shell or agent is live, a settled shell's
|
|
4785
|
+
* continuation is due (`continuationGraceMs`), a registry read is due
|
|
4786
|
+
* or under way, or the task registry has failed to answer: in the last
|
|
4787
|
+
* two "nothing is running" is not known (`backgroundWorkHoldsRuntime`
|
|
4788
|
+
* holds the runtime on the same grounds).
|
|
4789
|
+
*/
|
|
4790
|
+
function releaseHold() {
|
|
4791
|
+
if (pendingHumanRequests > 0) return 'question';
|
|
4792
|
+
if (isCompacting()) return 'compaction';
|
|
4793
|
+
if (
|
|
4794
|
+
activeTurn || publishingTurns.size > 0 || pendingRelayToolCalls > 0
|
|
4795
|
+
|| directImageMessages.size > 0 || admittingDeliveries > 0
|
|
4796
|
+
) return 'turn';
|
|
4797
|
+
if (backgroundShells.size() > 0 || backgroundTasks.size > 0) return 'background';
|
|
4798
|
+
if (continuationDueSince && Date.now() - continuationDueSince < continuationGraceMs) return 'background';
|
|
4799
|
+
// A read of the registry that is debounced, queued or on its way may
|
|
4800
|
+
// still find a live agent or shell the last answer did not have.
|
|
4801
|
+
if (sessionRpc.tasks && (taskRefreshTimer || taskRefreshQueued > 0 || taskRefreshInFlight)) return 'background';
|
|
4802
|
+
if (sessionRpc.tasks && taskRefreshFailures > 0 && taskRefreshFailures < MAX_TASK_REFRESH_FAILURES) return 'background';
|
|
4803
|
+
return '';
|
|
4691
4804
|
}
|
|
4692
4805
|
|
|
4693
4806
|
/**
|
|
@@ -4831,6 +4944,18 @@ export function createCopilotSdkSessionRunner({
|
|
|
4831
4944
|
}
|
|
4832
4945
|
|
|
4833
4946
|
async function handlePendingPayload(pending) {
|
|
4947
|
+
// A payload without a message is refused at once, as it always was: it
|
|
4948
|
+
// admits nothing.
|
|
4949
|
+
if (!pending?.message) return false;
|
|
4950
|
+
admittingDeliveries += 1;
|
|
4951
|
+
try {
|
|
4952
|
+
return await admitPendingPayload(pending);
|
|
4953
|
+
} finally {
|
|
4954
|
+
admittingDeliveries -= 1;
|
|
4955
|
+
}
|
|
4956
|
+
}
|
|
4957
|
+
|
|
4958
|
+
async function admitPendingPayload(pending) {
|
|
4834
4959
|
const message = pending?.message || null;
|
|
4835
4960
|
if (!message) return false;
|
|
4836
4961
|
// An image model is no chat model: its row goes to the relay's image
|
|
@@ -5024,6 +5149,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
5024
5149
|
// never awaits this, which is what keeps a slow relay from holding a
|
|
5025
5150
|
// finished reply.
|
|
5026
5151
|
whenUsagePosted: () => usagePostChain,
|
|
5152
|
+
whenTokensPosted: () => tokenPostChain,
|
|
5027
5153
|
// The in-flight model-catalog snapshot POST — the same kind of seam.
|
|
5028
5154
|
whenModelSnapshotPosted: () => modelSnapshotChain,
|
|
5029
5155
|
// The in-flight queued-lane read — same kind of seam.
|