@oar-sh/oar 0.9.6 → 0.9.8
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/CHANGELOG.md +285 -0
- package/README.md +222 -40
- package/bin/oar.js +320 -18
- package/package.json +1 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
- package/server/claude-worker/claude-session-process.mjs +225 -24
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +17 -0
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +149 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +468 -88
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +11 -0
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +145 -11
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +698 -28
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/pwa-install.js +5 -0
- package/server/public/app/relay-error-ctas.mjs +16 -2
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +37 -3
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +53 -1
- package/server/public/app/store.js +26 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +564 -20
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/relay-tools.md +13 -1
- package/server/repositories/remote-relay-repository.mjs +42 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +123 -16
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +517 -74
- package/server/server-runtime.mjs +268 -33
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/oar-cli-helpers.mjs +67 -1
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/remote-relay-caller-context.mjs +49 -10
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +383 -35
- package/server/services/remote-relay-inbound.mjs +49 -2
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +348 -129
- package/server/services/session-worker-registry-service.mjs +18 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +56 -1
- package/server/services/update-install-service.mjs +9 -3
- package/server/services/usage-limit-pause-service.mjs +3 -1
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/remote-relay-contract.mjs +153 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +1 -0
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
* When the experimental call is unavailable the worker still reports the
|
|
14
14
|
* stable `modelUsage` / `total_cost_usd` result fields, which render as session
|
|
15
15
|
* cost details with no plan meters.
|
|
16
|
+
*
|
|
17
|
+
* A second, live source exists while the Claude Cloud provider is switched on
|
|
18
|
+
* (the switch is the consent to use the Claude CLI's login): the account's
|
|
19
|
+
* usage as claude.ai's own endpoint reports it, read without a model turn
|
|
20
|
+
* (`claude-account-usage-service.mjs`). It carries the same window keys, so
|
|
21
|
+
* the meters are then built from it and the SDK snapshot only supplies the
|
|
22
|
+
* session details.
|
|
16
23
|
*/
|
|
17
24
|
|
|
18
25
|
import {
|
|
19
26
|
SOURCE_CACHE,
|
|
27
|
+
SOURCE_LIVE,
|
|
20
28
|
SOURCE_WORKER,
|
|
21
29
|
STATUS_NOT_CONFIGURED,
|
|
22
30
|
STATUS_OK,
|
|
@@ -30,13 +38,14 @@ import {
|
|
|
30
38
|
roundCurrency,
|
|
31
39
|
toFiniteNumber,
|
|
32
40
|
toIsoTimestamp,
|
|
41
|
+
toSeverity,
|
|
33
42
|
toTrimmedString,
|
|
34
43
|
} from './plan-usage-contract.mjs';
|
|
35
44
|
|
|
36
45
|
export const CLAUDE_PROVIDER_ID = 'claude';
|
|
37
46
|
export const CLAUDE_LABEL = 'Claude';
|
|
38
47
|
|
|
39
|
-
const CLAUDE_USAGE_URL = 'https://claude.ai/settings/usage';
|
|
48
|
+
export const CLAUDE_USAGE_URL = 'https://claude.ai/settings/usage';
|
|
40
49
|
|
|
41
50
|
// Fixed windows, in display order. Model-scoped windows are appended after
|
|
42
51
|
// these from the server-supplied `model_scoped[]` array.
|
|
@@ -139,7 +148,7 @@ export function normalizeClaudePlanUsage(raw) {
|
|
|
139
148
|
const displayName = toTrimmedString(entry?.display_name);
|
|
140
149
|
if (!normalized || !displayName) continue;
|
|
141
150
|
windows.push({
|
|
142
|
-
id:
|
|
151
|
+
id: modelScopedWindowId(displayName),
|
|
143
152
|
label: `Weekly ${displayName}`,
|
|
144
153
|
emphasis: 'secondary',
|
|
145
154
|
...normalized,
|
|
@@ -157,6 +166,7 @@ export function normalizeClaudePlanUsage(raw) {
|
|
|
157
166
|
usedCredits: toFiniteNumber(rawExtra.used_credits),
|
|
158
167
|
utilization: clampPercent(rawExtra.utilization),
|
|
159
168
|
currency: toTrimmedString(rawExtra.currency),
|
|
169
|
+
decimalPlaces: toFiniteNumber(rawExtra.decimal_places),
|
|
160
170
|
}
|
|
161
171
|
: null;
|
|
162
172
|
|
|
@@ -184,6 +194,75 @@ export function normalizeClaudePlanUsage(raw) {
|
|
|
184
194
|
return hasSignal ? payload : null;
|
|
185
195
|
}
|
|
186
196
|
|
|
197
|
+
function modelScopedWindowId(displayName) {
|
|
198
|
+
return `model_scoped:${displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// `limits[]` of the account endpoint names the two fixed windows by kind.
|
|
202
|
+
const LIMIT_KIND_WINDOW_IDS = Object.freeze({ session: 'five_hour', weekly_all: 'seven_day' });
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Normalize the live account usage (the body of claude.ai's usage endpoint).
|
|
206
|
+
*
|
|
207
|
+
* Its top level is what the SDK response carries under `rate_limits`, so the
|
|
208
|
+
* windows and the extra usage come from the same normalizer. On top of that:
|
|
209
|
+
* `limits[]` adds a severity per window and the model-scoped weekly windows,
|
|
210
|
+
* and `seven_day_breakdown.rows` says which product the week went to.
|
|
211
|
+
* Returns `{ windows, extraUsage, breakdown }`, or null when nothing usable
|
|
212
|
+
* is present.
|
|
213
|
+
*/
|
|
214
|
+
export function normalizeClaudeAccountUsage(body) {
|
|
215
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
|
|
216
|
+
const base = normalizeClaudePlanUsage({ rate_limits: body, rate_limits_available: true });
|
|
217
|
+
const windows = (base?.windows || []).map((window) => ({ ...window, severity: null }));
|
|
218
|
+
|
|
219
|
+
for (const entry of (Array.isArray(body.limits) ? body.limits : [])) {
|
|
220
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
221
|
+
const kind = toTrimmedString(entry.kind);
|
|
222
|
+
let target = null;
|
|
223
|
+
if (kind === 'weekly_scoped') {
|
|
224
|
+
const displayName = toTrimmedString(entry.scope?.model?.display_name)
|
|
225
|
+
|| toTrimmedString(entry.scope?.surface?.display_name);
|
|
226
|
+
if (!displayName) continue;
|
|
227
|
+
const label = `Weekly ${displayName}`;
|
|
228
|
+
target = windows.find((window) => window.label.toLowerCase() === label.toLowerCase()) || null;
|
|
229
|
+
if (!target) {
|
|
230
|
+
const reading = normalizeWindow({ utilization: entry.percent, resets_at: entry.resets_at });
|
|
231
|
+
if (!reading) continue;
|
|
232
|
+
target = { id: modelScopedWindowId(displayName), label, emphasis: 'secondary', ...reading, severity: null };
|
|
233
|
+
windows.push(target);
|
|
234
|
+
}
|
|
235
|
+
} else if (kind && LIMIT_KIND_WINDOW_IDS[kind]) {
|
|
236
|
+
target = windows.find((window) => window.id === LIMIT_KIND_WINDOW_IDS[kind]) || null;
|
|
237
|
+
}
|
|
238
|
+
if (target) target.severity = toSeverity(entry.severity);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const breakdown = (Array.isArray(body.seven_day_breakdown?.rows) ? body.seven_day_breakdown.rows : [])
|
|
242
|
+
.map((row) => {
|
|
243
|
+
const name = toTrimmedString(row?.display_name) || toTrimmedString(row?.key);
|
|
244
|
+
const percent = clampPercent(row?.percent);
|
|
245
|
+
return name && percent !== null ? { name, percent } : null;
|
|
246
|
+
})
|
|
247
|
+
.filter(Boolean);
|
|
248
|
+
|
|
249
|
+
const extraUsage = base?.extraUsage || null;
|
|
250
|
+
if (!windows.length && !extraUsage && !breakdown.length) return null;
|
|
251
|
+
return { windows, extraUsage, breakdown };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The prepaid credits of the organisation as `{ amount, currency }` in major
|
|
256
|
+
* units, or null. The API sends the balance as `amount` in minor units (the
|
|
257
|
+
* Claude CLI reads it as cents).
|
|
258
|
+
*/
|
|
259
|
+
export function normalizeClaudePrepaidCredits(raw) {
|
|
260
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
261
|
+
const minor = toFiniteNumber(raw.amount);
|
|
262
|
+
if (minor === null) return null;
|
|
263
|
+
return { amount: minor / 100, currency: toTrimmedString(raw.currency) };
|
|
264
|
+
}
|
|
265
|
+
|
|
187
266
|
/** Build the fallback payload from the stable result-message fields. */
|
|
188
267
|
export function claudePlanUsageFromResult({ modelUsage = null, totalCostUsd = null } = {}) {
|
|
189
268
|
const models = normalizeModelUsageMap(modelUsage);
|
|
@@ -232,6 +311,68 @@ function formatUsd(value) {
|
|
|
232
311
|
return numeric === null ? null : `$${numeric.toFixed(2)}`;
|
|
233
312
|
}
|
|
234
313
|
|
|
314
|
+
/** "$12.50" for dollars (or an unnamed currency), "12.50 EUR" for any other. */
|
|
315
|
+
export function formatClaudeMoney(amount, currency = null) {
|
|
316
|
+
const numeric = roundCurrency(amount);
|
|
317
|
+
if (numeric === null) return null;
|
|
318
|
+
const code = toTrimmedString(currency)?.toUpperCase() || 'USD';
|
|
319
|
+
return code === 'USD' ? `$${numeric.toFixed(2)}` : `${numeric.toFixed(2)} ${code}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Extra-usage amounts arrive in minor units of the currency (cents for USD;
|
|
324
|
+
* the SDK's response schema says so, and the account endpoint names the
|
|
325
|
+
* exponent in `decimal_places`).
|
|
326
|
+
*/
|
|
327
|
+
function extraUsageAmount(value, decimalPlaces) {
|
|
328
|
+
const numeric = toFiniteNumber(value);
|
|
329
|
+
if (numeric === null) return null;
|
|
330
|
+
const places = Number.isInteger(decimalPlaces) && decimalPlaces >= 0 && decimalPlaces <= 4 ? decimalPlaces : 2;
|
|
331
|
+
return numeric / (10 ** places);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The detail sections for one session's totals (`session` as
|
|
336
|
+
* `normalizeClaudePlanUsage` / `claudePlanUsageFromResult` store it). The
|
|
337
|
+
* Claude Cloud card shows its own latest snapshot through the same sections,
|
|
338
|
+
* under its own ids and wording.
|
|
339
|
+
*/
|
|
340
|
+
export function buildClaudeSessionSections(session, {
|
|
341
|
+
idPrefix = 'claude',
|
|
342
|
+
sessionLabel = 'Latest session totals',
|
|
343
|
+
modelsLabel = 'By model (session)',
|
|
344
|
+
costLabel = 'Session cost (estimate)',
|
|
345
|
+
costHint = 'Client-side estimate, not a billing statement',
|
|
346
|
+
} = {}) {
|
|
347
|
+
if (!session || typeof session !== 'object') return [];
|
|
348
|
+
const sections = [];
|
|
349
|
+
const rows = [];
|
|
350
|
+
const cost = formatUsd(session.totalCostUsd);
|
|
351
|
+
if (cost) rows.push({ label: costLabel, value: cost, hint: costHint });
|
|
352
|
+
const apiDuration = formatDuration(session.totalApiDurationMs);
|
|
353
|
+
if (apiDuration) rows.push({ label: 'API time', value: apiDuration });
|
|
354
|
+
const wallDuration = formatDuration(session.totalDurationMs);
|
|
355
|
+
if (wallDuration) rows.push({ label: 'Wall-clock time', value: wallDuration });
|
|
356
|
+
if (toFiniteNumber(session.totalLinesAdded) !== null) rows.push({ label: 'Lines added', value: String(session.totalLinesAdded) });
|
|
357
|
+
if (toFiniteNumber(session.totalLinesRemoved) !== null) rows.push({ label: 'Lines removed', value: String(session.totalLinesRemoved) });
|
|
358
|
+
const sessionSection = buildDetailSection({ id: `${idPrefix}-session`, label: sessionLabel, rows });
|
|
359
|
+
if (sessionSection) sections.push(sessionSection);
|
|
360
|
+
|
|
361
|
+
const modelRows = (Array.isArray(session.modelUsage) ? session.modelUsage : []).map((entry) => ({
|
|
362
|
+
label: entry.model,
|
|
363
|
+
value: formatUsd(entry.costUsd) ?? '—',
|
|
364
|
+
hint: [
|
|
365
|
+
formatTokens(entry.inputTokens) ? `in ${formatTokens(entry.inputTokens)}` : null,
|
|
366
|
+
formatTokens(entry.outputTokens) ? `out ${formatTokens(entry.outputTokens)}` : null,
|
|
367
|
+
formatTokens(entry.cacheReadTokens) ? `cache r ${formatTokens(entry.cacheReadTokens)}` : null,
|
|
368
|
+
formatTokens(entry.cacheWriteTokens) ? `cache w ${formatTokens(entry.cacheWriteTokens)}` : null,
|
|
369
|
+
].filter(Boolean).join(' · ') || null,
|
|
370
|
+
}));
|
|
371
|
+
const modelSection = buildDetailSection({ id: `${idPrefix}-models`, label: modelsLabel, rows: modelRows });
|
|
372
|
+
if (modelSection) sections.push(modelSection);
|
|
373
|
+
return sections;
|
|
374
|
+
}
|
|
375
|
+
|
|
235
376
|
function attributionRows(entries, limit = 6) {
|
|
236
377
|
return entries.slice(0, limit).map((entry) => ({
|
|
237
378
|
label: entry.name,
|
|
@@ -280,12 +421,20 @@ function behaviorSections(behaviors) {
|
|
|
280
421
|
return sections;
|
|
281
422
|
}
|
|
282
423
|
|
|
424
|
+
/**
|
|
425
|
+
* @param {object} options
|
|
426
|
+
* @param {object|null} options.usage the stored snapshot of the last SDK turn
|
|
427
|
+
* @param {object|null} options.account live account usage
|
|
428
|
+
* (`{ usage, prepaid, offer, fetchedAt, error }` from
|
|
429
|
+
* claude-account-usage-service.mjs), or null when it was not read
|
|
430
|
+
*/
|
|
283
431
|
export function buildClaudePlanCard({
|
|
284
432
|
usage = null,
|
|
285
433
|
capturedAt = null,
|
|
286
434
|
stale = false,
|
|
287
435
|
configured = true,
|
|
288
436
|
message = null,
|
|
437
|
+
account = null,
|
|
289
438
|
} = {}) {
|
|
290
439
|
if (!configured) {
|
|
291
440
|
return buildUnavailableCard({
|
|
@@ -295,7 +444,11 @@ export function buildClaudePlanCard({
|
|
|
295
444
|
message: 'Claude is not enabled in provider settings.',
|
|
296
445
|
});
|
|
297
446
|
}
|
|
298
|
-
|
|
447
|
+
const live = normalizeClaudeAccountUsage(account?.usage);
|
|
448
|
+
// Live windows replace the snapshot's; a live answer without a single
|
|
449
|
+
// window (it should not happen for a plan login) leaves the snapshot in place.
|
|
450
|
+
const hasLive = !!live && live.windows.length > 0;
|
|
451
|
+
if (!usage && !hasLive) {
|
|
299
452
|
return buildUnavailableCard({
|
|
300
453
|
provider: CLAUDE_PROVIDER_ID,
|
|
301
454
|
label: CLAUDE_LABEL,
|
|
@@ -307,7 +460,8 @@ export function buildClaudePlanCard({
|
|
|
307
460
|
}
|
|
308
461
|
|
|
309
462
|
const meters = [];
|
|
310
|
-
|
|
463
|
+
const windows = hasLive ? live.windows : (Array.isArray(usage.windows) ? usage.windows : []);
|
|
464
|
+
for (const window of windows) {
|
|
311
465
|
meters.push(buildMeter({
|
|
312
466
|
id: `claude-${window.id}`,
|
|
313
467
|
label: window.label,
|
|
@@ -315,17 +469,18 @@ export function buildClaudePlanCard({
|
|
|
315
469
|
utilization: window.utilization,
|
|
316
470
|
resetAt: window.resetsAt,
|
|
317
471
|
emphasis: window.emphasis === 'secondary' ? 'secondary' : 'primary',
|
|
472
|
+
severity: window.severity,
|
|
318
473
|
}));
|
|
319
474
|
}
|
|
320
475
|
|
|
321
|
-
const extra = usage.extraUsage;
|
|
476
|
+
const extra = hasLive ? live.extraUsage : usage.extraUsage;
|
|
322
477
|
if (extra && (extra.isEnabled || extra.usedCredits !== null || extra.monthlyLimit !== null)) {
|
|
323
478
|
meters.push(buildMeter({
|
|
324
479
|
id: 'claude-extra-usage',
|
|
325
480
|
label: 'Extra usage credits',
|
|
326
481
|
unit: extra.currency && extra.currency.toUpperCase() !== 'USD' ? 'credits' : 'usd',
|
|
327
|
-
used: extra.usedCredits,
|
|
328
|
-
allowance: extra.monthlyLimit,
|
|
482
|
+
used: extraUsageAmount(extra.usedCredits, extra.decimalPlaces),
|
|
483
|
+
allowance: extraUsageAmount(extra.monthlyLimit, extra.decimalPlaces),
|
|
329
484
|
utilization: extra.utilization,
|
|
330
485
|
note: extra.isEnabled ? null : 'Extra usage is disabled for this account',
|
|
331
486
|
emphasis: 'secondary',
|
|
@@ -333,55 +488,62 @@ export function buildClaudePlanCard({
|
|
|
333
488
|
}
|
|
334
489
|
|
|
335
490
|
const details = [];
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (apiDuration) rows.push({ label: 'API time', value: apiDuration });
|
|
343
|
-
const wallDuration = formatDuration(session.totalDurationMs);
|
|
344
|
-
if (wallDuration) rows.push({ label: 'Wall-clock time', value: wallDuration });
|
|
345
|
-
if (session.totalLinesAdded !== null) rows.push({ label: 'Lines added', value: String(session.totalLinesAdded) });
|
|
346
|
-
if (session.totalLinesRemoved !== null) rows.push({ label: 'Lines removed', value: String(session.totalLinesRemoved) });
|
|
347
|
-
const sessionSection = buildDetailSection({ id: 'claude-session', label: 'Latest session totals', rows });
|
|
348
|
-
if (sessionSection) details.push(sessionSection);
|
|
349
|
-
|
|
350
|
-
const modelRows = (session.modelUsage || []).map((entry) => ({
|
|
351
|
-
label: entry.model,
|
|
352
|
-
value: formatUsd(entry.costUsd) ?? '—',
|
|
353
|
-
hint: [
|
|
354
|
-
formatTokens(entry.inputTokens) ? `in ${formatTokens(entry.inputTokens)}` : null,
|
|
355
|
-
formatTokens(entry.outputTokens) ? `out ${formatTokens(entry.outputTokens)}` : null,
|
|
356
|
-
formatTokens(entry.cacheReadTokens) ? `cache r ${formatTokens(entry.cacheReadTokens)}` : null,
|
|
357
|
-
formatTokens(entry.cacheWriteTokens) ? `cache w ${formatTokens(entry.cacheWriteTokens)}` : null,
|
|
358
|
-
].filter(Boolean).join(' · ') || null,
|
|
491
|
+
if (hasLive && live.breakdown.length) {
|
|
492
|
+
details.push(buildDetailSection({
|
|
493
|
+
id: 'claude-week-by-product',
|
|
494
|
+
label: 'This week by product',
|
|
495
|
+
note: 'Share of this week’s usage per product, as claude.ai reports it. Cloud sessions count under Claude Code.',
|
|
496
|
+
rows: live.breakdown.map((row) => ({ label: row.name, value: `${Math.round(row.percent * 10) / 10}%` })),
|
|
359
497
|
}));
|
|
360
|
-
const modelSection = buildDetailSection({ id: 'claude-models', label: 'By model (session)', rows: modelRows });
|
|
361
|
-
if (modelSection) details.push(modelSection);
|
|
362
498
|
}
|
|
499
|
+
const prepaid = normalizeClaudePrepaidCredits(account?.prepaid);
|
|
500
|
+
if (prepaid && prepaid.amount !== 0) {
|
|
501
|
+
details.push(buildDetailSection({
|
|
502
|
+
id: 'claude-credits',
|
|
503
|
+
label: 'Credits',
|
|
504
|
+
rows: [{ label: 'Prepaid credits', value: formatClaudeMoney(prepaid.amount, prepaid.currency), hint: 'Balance of the organisation' }],
|
|
505
|
+
}));
|
|
506
|
+
}
|
|
507
|
+
details.push(...buildClaudeSessionSections(usage?.session));
|
|
508
|
+
details.push(...behaviorSections(usage?.behaviors));
|
|
363
509
|
|
|
364
|
-
|
|
510
|
+
const notes = [];
|
|
511
|
+
if (hasLive) {
|
|
512
|
+
// The claude.ai usage page also lists limit-reset vouchers; no endpoint
|
|
513
|
+
// the CLI login reaches has a field for them.
|
|
514
|
+
notes.push({
|
|
515
|
+
id: 'claude-limit-resets',
|
|
516
|
+
text: 'Limit resets (full / 5-hour) are shown on claude.ai only.',
|
|
517
|
+
link: { label: 'Open the usage page', url: CLAUDE_USAGE_URL },
|
|
518
|
+
});
|
|
519
|
+
} else if (toTrimmedString(account?.error)) {
|
|
520
|
+
notes.push({
|
|
521
|
+
id: 'claude-live-unavailable',
|
|
522
|
+
text: `Live account usage could not be read, so this is the reading of the last Claude turn. ${toTrimmedString(account.error)}`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
365
525
|
|
|
366
526
|
const resolvedMeters = meters.filter(Boolean);
|
|
367
527
|
const planLimitsMissing = !resolvedMeters.length;
|
|
528
|
+
const subscriptionType = toTrimmedString(usage?.subscriptionType);
|
|
368
529
|
return buildProviderCard({
|
|
369
530
|
provider: CLAUDE_PROVIDER_ID,
|
|
370
531
|
label: CLAUDE_LABEL,
|
|
371
532
|
status: planLimitsMissing ? STATUS_PARTIAL : STATUS_OK,
|
|
372
|
-
planName:
|
|
373
|
-
? `${
|
|
533
|
+
planName: subscriptionType
|
|
534
|
+
? `${subscriptionType.charAt(0).toUpperCase()}${subscriptionType.slice(1)}`
|
|
374
535
|
: null,
|
|
375
536
|
message: planLimitsMissing
|
|
376
|
-
? (usage
|
|
537
|
+
? (hasLive || usage?.rateLimitsAvailable
|
|
377
538
|
? 'Plan rate limits were not reported for this session.'
|
|
378
539
|
: 'This session authenticates without claude.ai plan limits (API key or third-party provider), so only session cost is available.')
|
|
379
540
|
: null,
|
|
380
|
-
source: stale ? SOURCE_CACHE : SOURCE_WORKER,
|
|
381
|
-
stale,
|
|
382
|
-
capturedAt,
|
|
541
|
+
source: hasLive ? SOURCE_LIVE : (stale ? SOURCE_CACHE : SOURCE_WORKER),
|
|
542
|
+
stale: hasLive ? false : stale,
|
|
543
|
+
capturedAt: hasLive ? (toIsoTimestamp(account?.fetchedAt) || capturedAt) : capturedAt,
|
|
383
544
|
meters: resolvedMeters,
|
|
384
|
-
details,
|
|
545
|
+
details: details.filter(Boolean),
|
|
546
|
+
notes,
|
|
385
547
|
links: [{ label: 'Claude usage settings', url: CLAUDE_USAGE_URL }],
|
|
386
548
|
});
|
|
387
549
|
}
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* while context usage describes one session's token window.
|
|
10
10
|
*
|
|
11
11
|
* Every provider normalizer in `plan-usage-<provider>.mjs` emits the same
|
|
12
|
-
* `{ card: { meters, details, links } }` shape so the browser renderer
|
|
13
|
-
* provider-agnostic, and so a provider that can only report *some* of
|
|
14
|
-
* limits degrades to fewer meters rather than to a special-cased layout.
|
|
12
|
+
* `{ card: { meters, details, notes, links } }` shape so the browser renderer
|
|
13
|
+
* stays provider-agnostic, and so a provider that can only report *some* of
|
|
14
|
+
* its limits degrades to fewer meters rather than to a special-cased layout.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
export const PLAN_USAGE_VERSION = 2;
|
|
@@ -134,6 +134,16 @@ export function resolveMeterMath({
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* A provider's severity word as a plain lowercase token ("normal",
|
|
139
|
+
* "warning", …). The vocabulary is the provider's; anything that is not a
|
|
140
|
+
* simple word is dropped rather than shown.
|
|
141
|
+
*/
|
|
142
|
+
export function toSeverity(value) {
|
|
143
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
144
|
+
return /^[a-z][a-z0-9_-]{0,23}$/.test(text) ? text : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
/**
|
|
138
148
|
* @param {object} spec
|
|
139
149
|
* @param {string} spec.id stable key, used by tests and DOM ids
|
|
@@ -141,6 +151,8 @@ export function resolveMeterMath({
|
|
|
141
151
|
* @param {'credits'|'requests'|'usd'|'percent'|'tokens'} [spec.unit]
|
|
142
152
|
* @param {boolean} [spec.unlimited] plan grants unlimited use of this bucket
|
|
143
153
|
* @param {boolean} [spec.estimated] derived/manual rather than provider-authoritative
|
|
154
|
+
* @param {string} [spec.severity] the provider's own word for how close the limit is
|
|
155
|
+
* @param {'expiry'} [spec.resetKind] `resetAt` is when the allowance ends for good, not when it refills
|
|
144
156
|
* @returns {object|null} null when the meter carries no usable signal at all
|
|
145
157
|
*/
|
|
146
158
|
export function buildMeter({
|
|
@@ -156,6 +168,8 @@ export function buildMeter({
|
|
|
156
168
|
unlimited = false,
|
|
157
169
|
estimated = false,
|
|
158
170
|
emphasis = 'primary',
|
|
171
|
+
severity = null,
|
|
172
|
+
resetKind = null,
|
|
159
173
|
} = {}) {
|
|
160
174
|
const meterId = toTrimmedString(id);
|
|
161
175
|
const meterLabel = toTrimmedString(label);
|
|
@@ -181,10 +195,29 @@ export function buildMeter({
|
|
|
181
195
|
remaining: math.remaining,
|
|
182
196
|
utilization: math.utilization,
|
|
183
197
|
resetAt: toIsoTimestamp(resetAt),
|
|
198
|
+
resetKind: resetKind === 'expiry' ? 'expiry' : null,
|
|
199
|
+
severity: toSeverity(severity),
|
|
184
200
|
note: toTrimmedString(note),
|
|
185
201
|
};
|
|
186
202
|
}
|
|
187
203
|
|
|
204
|
+
/**
|
|
205
|
+
* A line of text on the card itself (not inside a collapsed section), with an
|
|
206
|
+
* optional link behind it. Same link rule as the card's footer: https only.
|
|
207
|
+
*/
|
|
208
|
+
export function buildCardNote({ id, text, link = null } = {}) {
|
|
209
|
+
const noteId = toTrimmedString(id);
|
|
210
|
+
const noteText = toTrimmedString(text);
|
|
211
|
+
if (!noteId || !noteText) return null;
|
|
212
|
+
const linkLabel = toTrimmedString(link?.label);
|
|
213
|
+
const url = toTrimmedString(link?.url);
|
|
214
|
+
return {
|
|
215
|
+
id: noteId,
|
|
216
|
+
text: noteText,
|
|
217
|
+
link: linkLabel && url && /^https:\/\//i.test(url) ? { label: linkLabel, url } : null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
188
221
|
/** A collapsible block of label/value rows shown under the meters. */
|
|
189
222
|
export function buildDetailSection({ id, label, rows = [], note = null } = {}) {
|
|
190
223
|
const sectionId = toTrimmedString(id);
|
|
@@ -214,6 +247,7 @@ export function buildProviderCard({
|
|
|
214
247
|
stale = false,
|
|
215
248
|
meters = [],
|
|
216
249
|
details = [],
|
|
250
|
+
notes = [],
|
|
217
251
|
links = [],
|
|
218
252
|
} = {}) {
|
|
219
253
|
const providerId = toTrimmedString(provider);
|
|
@@ -222,6 +256,7 @@ export function buildProviderCard({
|
|
|
222
256
|
|
|
223
257
|
const normalizedMeters = (Array.isArray(meters) ? meters : []).filter(Boolean);
|
|
224
258
|
const normalizedDetails = (Array.isArray(details) ? details : []).filter(Boolean);
|
|
259
|
+
const normalizedNotes = (Array.isArray(notes) ? notes : []).map((note) => buildCardNote(note || {})).filter(Boolean);
|
|
225
260
|
const normalizedLinks = (Array.isArray(links) ? links : [])
|
|
226
261
|
.map((link) => {
|
|
227
262
|
const linkLabel = toTrimmedString(link?.label);
|
|
@@ -245,6 +280,7 @@ export function buildProviderCard({
|
|
|
245
280
|
stale: stale === true,
|
|
246
281
|
meters: normalizedMeters,
|
|
247
282
|
details: normalizedDetails,
|
|
283
|
+
notes: normalizedNotes,
|
|
248
284
|
links: normalizedLinks,
|
|
249
285
|
};
|
|
250
286
|
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from './plan-usage-contract.mjs';
|
|
21
21
|
import { buildCopilotPlanCard } from './plan-usage-copilot.mjs';
|
|
22
22
|
import { buildClaudePlanCard } from './plan-usage-claude.mjs';
|
|
23
|
+
import { CLAUDE_CLOUD_PROVIDER_ID, buildClaudeCloudPlanCard } from './plan-usage-claude-cloud.mjs';
|
|
23
24
|
import {
|
|
24
25
|
CURSOR_POOLS,
|
|
25
26
|
applyCursorUsageDelta,
|
|
@@ -383,6 +384,59 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
383
384
|
return { cycle };
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
// Claude Cloud conversations keep their reported cost on the runtime
|
|
388
|
+
// session row (migration 0007). That table is the relay's, not this
|
|
389
|
+
// service's, so the statements are prepared on first use, and only once
|
|
390
|
+
// the table and the column are there (an older database has neither).
|
|
391
|
+
let claudeCloudSpendStmts = null;
|
|
392
|
+
function claudeCloudSpendStatements() {
|
|
393
|
+
if (claudeCloudSpendStmts) return claudeCloudSpendStmts;
|
|
394
|
+
const columns = new Set(db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column.name)));
|
|
395
|
+
if (!columns.has('claude_cloud_cost_usd') || !columns.has('provider_type')) return null;
|
|
396
|
+
claudeCloudSpendStmts = {
|
|
397
|
+
totals: db.prepare(`
|
|
398
|
+
SELECT COUNT(*) AS conversation_count, COALESCE(SUM(claude_cloud_cost_usd), 0) AS total_usd
|
|
399
|
+
FROM runtime_sessions
|
|
400
|
+
WHERE LOWER(TRIM(provider_type)) = '${CLAUDE_CLOUD_PROVIDER_ID}'
|
|
401
|
+
`),
|
|
402
|
+
byConversation: db.prepare(`
|
|
403
|
+
SELECT claude_cloud_cost_usd AS cost_usd
|
|
404
|
+
FROM runtime_sessions
|
|
405
|
+
WHERE conversation_id = ? AND LOWER(TRIM(provider_type)) = '${CLAUDE_CLOUD_PROVIDER_ID}'
|
|
406
|
+
`),
|
|
407
|
+
};
|
|
408
|
+
return claudeCloudSpendStmts;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* What the relay's cloud conversations have cost: the sum of the latest
|
|
413
|
+
* cost each one reported and how many there are, plus the cost of
|
|
414
|
+
* `conversationId` when that is a cloud conversation (`conversation` is
|
|
415
|
+
* null for any other). Only conversations that still exist count: a deleted
|
|
416
|
+
* one takes its row with it. There are no dates on these figures, so no
|
|
417
|
+
* per-day or per-week sum is offered. Null when the storage is not there.
|
|
418
|
+
*/
|
|
419
|
+
function readClaudeCloudSpend({ conversationId = '' } = {}) {
|
|
420
|
+
try {
|
|
421
|
+
const statements = claudeCloudSpendStatements();
|
|
422
|
+
if (!statements) return null;
|
|
423
|
+
const totals = statements.totals.get();
|
|
424
|
+
const id = toTrimmedString(conversationId);
|
|
425
|
+
const row = id ? statements.byConversation.get(id) : null;
|
|
426
|
+
const cost = row ? Number(row.cost_usd) : null;
|
|
427
|
+
return {
|
|
428
|
+
totalUsd: Number(totals?.total_usd) || 0,
|
|
429
|
+
conversationCount: Number(totals?.conversation_count) || 0,
|
|
430
|
+
conversation: row
|
|
431
|
+
? { costUsd: row.cost_usd !== null && Number.isFinite(cost) ? cost : null }
|
|
432
|
+
: null,
|
|
433
|
+
};
|
|
434
|
+
} catch (error) {
|
|
435
|
+
dbg('claude cloud spend read failed', error?.message || String(error));
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
386
440
|
/**
|
|
387
441
|
* Assemble the full multi-provider report. Every provider is independent:
|
|
388
442
|
* one failing source produces an unavailable card instead of failing the
|
|
@@ -392,8 +446,17 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
392
446
|
copilotSummary = null,
|
|
393
447
|
copilotError = null,
|
|
394
448
|
copilotStale = false,
|
|
449
|
+
// When the quota answer is an earlier one served again (the modal asked
|
|
450
|
+
// for another provider live), the time it was really read.
|
|
451
|
+
copilotCapturedAt = null,
|
|
395
452
|
copilotBilling = null,
|
|
396
453
|
claudeConfigured = true,
|
|
454
|
+
// Live account usage (claude-account-usage-service.mjs), or null: it is
|
|
455
|
+
// only ever read while Claude Cloud is switched on.
|
|
456
|
+
claudeAccount = null,
|
|
457
|
+
claudeCloudConfigured = false,
|
|
458
|
+
// The conversation the modal was opened from, for its own cloud cost.
|
|
459
|
+
conversationId = '',
|
|
397
460
|
cursorConfigured = true,
|
|
398
461
|
cursorAllowances = null,
|
|
399
462
|
cursorBilling = null,
|
|
@@ -404,6 +467,9 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
404
467
|
} = {}) {
|
|
405
468
|
const generatedAt = nowIso();
|
|
406
469
|
const claudeSnapshot = readSnapshot('claude');
|
|
470
|
+
// What the cloud workers post to /api/claude-plan-usage is kept under its
|
|
471
|
+
// own key, so the Claude card stays what the Claude workers wrote.
|
|
472
|
+
const claudeCloudSnapshot = claudeCloudConfigured === true ? readSnapshot(CLAUDE_CLOUD_PROVIDER_ID) : null;
|
|
407
473
|
const allowances = normalizeCursorAllowanceSettings(cursorAllowances || {});
|
|
408
474
|
const cursorCycle = readCursorCycleTotals({ resetDay: allowances.resetDay });
|
|
409
475
|
const grokSettings = normalizeGrokAllowanceSettings(grokAllowances || {});
|
|
@@ -421,7 +487,7 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
421
487
|
billing: copilotBilling,
|
|
422
488
|
workerUsage: copilotWorkerSnapshot?.payload || null,
|
|
423
489
|
error: copilotError,
|
|
424
|
-
capturedAt: generatedAt,
|
|
490
|
+
capturedAt: copilotCapturedAt || generatedAt,
|
|
425
491
|
stale: copilotStale === true,
|
|
426
492
|
}),
|
|
427
493
|
buildClaudePlanCard({
|
|
@@ -431,9 +497,22 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
431
497
|
message: claudeSnapshot?.error || null,
|
|
432
498
|
// A stored snapshot is by definition from an earlier turn; the SDK's
|
|
433
499
|
// usage control call only works while a session transport is open, and
|
|
434
|
-
// the relay never opens a billable turn just to refresh this.
|
|
500
|
+
// the relay never opens a billable turn just to refresh this. Live
|
|
501
|
+
// account usage, where there is any, takes the meters over.
|
|
435
502
|
stale: !!claudeSnapshot?.payload,
|
|
503
|
+
account: claudeAccount,
|
|
436
504
|
}),
|
|
505
|
+
// Opt-in like Grok (no card while the provider is off), and placed next
|
|
506
|
+
// to the Claude card because both are the same account.
|
|
507
|
+
claudeCloudConfigured === true
|
|
508
|
+
? buildClaudeCloudPlanCard({
|
|
509
|
+
account: claudeAccount,
|
|
510
|
+
spend: readClaudeCloudSpend({ conversationId }),
|
|
511
|
+
usage: claudeCloudSnapshot?.payload || null,
|
|
512
|
+
capturedAt: claudeCloudSnapshot?.capturedAt || null,
|
|
513
|
+
configured: true,
|
|
514
|
+
})
|
|
515
|
+
: null,
|
|
437
516
|
buildCursorPlanCard({
|
|
438
517
|
totals: cursorCycle.totals,
|
|
439
518
|
allowances,
|
|
@@ -481,6 +560,7 @@ export function createPlanUsageService({ db, now = () => new Date(), dbg = () =>
|
|
|
481
560
|
recordGrokUsageReport,
|
|
482
561
|
readGrokCycleTotals,
|
|
483
562
|
resetGrokAccounting,
|
|
563
|
+
readClaudeCloudSpend,
|
|
484
564
|
buildReport,
|
|
485
565
|
pools: CURSOR_POOLS,
|
|
486
566
|
};
|
|
@@ -12,6 +12,7 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
|
|
|
12
12
|
export const CLAUDE_ULTRACODE_EFFORT = 'ultracode';
|
|
13
13
|
export const DEFAULT_CLAUDE_REASONING_EFFORTS = Object.freeze(['none', 'low', 'medium', 'high', 'xhigh', 'max', CLAUDE_ULTRACODE_EFFORT]);
|
|
14
14
|
export const DEFAULT_GROK_REASONING_EFFORTS = Object.freeze(['none', 'low', 'medium', 'high']);
|
|
15
|
+
export const CLAUDE_CLOUD_REASONING_EFFORTS = Object.freeze(['none']);
|
|
15
16
|
|
|
16
17
|
// Ultracode requires an xhigh-capable model (the SDK's stated gate), so the
|
|
17
18
|
// tier is derived from a model's discovered efforts rather than discovery —
|
|
@@ -54,6 +55,8 @@ export function supportedReasoningEffortsForProviderModel({
|
|
|
54
55
|
if (provider === 'grok') {
|
|
55
56
|
return effortsForModel(grokSettings?.effortsByModel, model) || [...DEFAULT_GROK_REASONING_EFFORTS];
|
|
56
57
|
}
|
|
58
|
+
// A cloud session takes no effort choice: the only tier is the default one.
|
|
59
|
+
if (provider === 'claude-cloud') return [...CLAUDE_CLOUD_REASONING_EFFORTS];
|
|
57
60
|
if (provider === 'openai' || provider === 'openai-image') {
|
|
58
61
|
const efforts = openAIReasoningEffortsForModel(model);
|
|
59
62
|
return Array.isArray(efforts) && efforts.length ? efforts.map(normalizeEffort).filter(Boolean) : null;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The installed app's icon and launch screen follow the Theme setting: the
|
|
2
|
+
// page links the manifest with `?theme=light` in Day mode, and the manifest
|
|
3
|
+
// then names the light icon set and a white background.
|
|
4
|
+
|
|
5
|
+
export const PWA_LIGHT_BACKGROUND = '#ffffff';
|
|
6
|
+
|
|
7
|
+
export function normalizePwaTheme(value) {
|
|
8
|
+
return String(value || '').trim().toLowerCase() === 'light' ? 'light' : 'dark';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function lightIconSrc(src) {
|
|
12
|
+
return String(src || '').replace(/(^|\/)app-icon(?=[-.])/, '$1app-icon-light');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The manifest for one theme. Dark is the template as it stands; light swaps
|
|
17
|
+
* every `app-icon*` source for its `app-icon-light*` twin and whitens the
|
|
18
|
+
* launch background and the toolbar colour.
|
|
19
|
+
*/
|
|
20
|
+
export function applyPwaManifestTheme(manifest, theme) {
|
|
21
|
+
if (normalizePwaTheme(theme) !== 'light') return manifest;
|
|
22
|
+
return {
|
|
23
|
+
...manifest,
|
|
24
|
+
background_color: PWA_LIGHT_BACKGROUND,
|
|
25
|
+
theme_color: PWA_LIGHT_BACKGROUND,
|
|
26
|
+
icons: Array.isArray(manifest?.icons)
|
|
27
|
+
? manifest.icons.map((icon) => ({ ...icon, src: lightIconSrc(icon?.src) }))
|
|
28
|
+
: manifest?.icons,
|
|
29
|
+
};
|
|
30
|
+
}
|