@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/README.md +222 -40
  3. package/bin/oar.js +320 -18
  4. package/package.json +1 -1
  5. package/server/README.md +174 -7
  6. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  7. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  10. package/server/claude-worker/claude-sdk-adapter.mjs +18 -13
  11. package/server/claude-worker/claude-session-process.mjs +225 -24
  12. package/server/claude-worker/claude-session-worker.mjs +6 -0
  13. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  14. package/server/db-schema.mjs +17 -0
  15. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  16. package/server/migrations/0007-claude-cloud.mjs +82 -0
  17. package/server/public/app/activity-replay-state.mjs +68 -5
  18. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  19. package/server/public/app/agent-sessions-settings.mjs +134 -0
  20. package/server/public/app/api-client.js +149 -6
  21. package/server/public/app/attachments-view.js +20 -1
  22. package/server/public/app/attribution-control.mjs +46 -0
  23. package/server/public/app/bootstrap.js +468 -88
  24. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  25. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  26. package/server/public/app/claude-cloud-ui.mjs +611 -0
  27. package/server/public/app/composer-control-state.mjs +11 -0
  28. package/server/public/app/context-menu.mjs +140 -0
  29. package/server/public/app/context-tier-options.mjs +4 -0
  30. package/server/public/app/context-usage-view.mjs +127 -8
  31. package/server/public/app/conversation-origin-ui.js +27 -0
  32. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  33. package/server/public/app/conversation-view.js +145 -11
  34. package/server/public/app/cwd-picker.js +12 -0
  35. package/server/public/app/journal-view.js +698 -28
  36. package/server/public/app/math-dollars.mjs +89 -0
  37. package/server/public/app/model-selector-options.mjs +1 -1
  38. package/server/public/app/plan-usage-view.mjs +36 -8
  39. package/server/public/app/pwa-install.js +5 -0
  40. package/server/public/app/relay-error-ctas.mjs +16 -2
  41. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  42. package/server/public/app/router.js +49 -12
  43. package/server/public/app/scrollbar-hover.mjs +78 -0
  44. package/server/public/app/settings-modal.js +37 -3
  45. package/server/public/app/settings-tabs.js +1 -1
  46. package/server/public/app/socket-handlers.js +53 -1
  47. package/server/public/app/store.js +26 -0
  48. package/server/public/app/transcript-separators.mjs +48 -11
  49. package/server/public/app-icon-192.png +0 -0
  50. package/server/public/app-icon-512.png +0 -0
  51. package/server/public/app-icon-light-192.png +0 -0
  52. package/server/public/app-icon-light-512.png +0 -0
  53. package/server/public/app-icon-light-maskable-512.png +0 -0
  54. package/server/public/app-icon-light.svg +4 -0
  55. package/server/public/app-icon-maskable-512.png +0 -0
  56. package/server/public/app-icon.svg +3 -3
  57. package/server/public/favicon.ico +0 -0
  58. package/server/public/index.html +564 -20
  59. package/server/public/manifest.webmanifest +9 -3
  60. package/server/public/sw.js +6 -1
  61. package/server/relay-tools.md +13 -1
  62. package/server/repositories/remote-relay-repository.mjs +42 -1
  63. package/server/repositories/session-repository.mjs +47 -1
  64. package/server/routes/claude-cloud-routes.mjs +140 -0
  65. package/server/routes/git-routes.mjs +37 -0
  66. package/server/routes/messages-routes.mjs +123 -16
  67. package/server/routes/remote-relay-routes.mjs +15 -1
  68. package/server/routes/sessions-routes.mjs +517 -74
  69. package/server/server-runtime.mjs +268 -33
  70. package/server/services/claude-account-usage-service.mjs +176 -0
  71. package/server/services/claude-cloud-repo-service.mjs +408 -0
  72. package/server/services/claude-cloud-session-service.mjs +326 -0
  73. package/server/services/claude-cloud-settings-service.mjs +269 -0
  74. package/server/services/claude-context-usage.mjs +51 -3
  75. package/server/services/git-remote-service.mjs +115 -0
  76. package/server/services/oar-cli-helpers.mjs +67 -1
  77. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  78. package/server/services/plan-usage-claude.mjs +202 -40
  79. package/server/services/plan-usage-contract.mjs +39 -3
  80. package/server/services/plan-usage-service.mjs +82 -2
  81. package/server/services/provider-reasoning-effort.mjs +3 -0
  82. package/server/services/pwa-manifest-theme.mjs +30 -0
  83. package/server/services/remote-relay-caller-context.mjs +49 -10
  84. package/server/services/remote-relay-client.mjs +9 -0
  85. package/server/services/remote-relay-dispatcher.mjs +383 -35
  86. package/server/services/remote-relay-inbound.mjs +49 -2
  87. package/server/services/remote-relay-loopback.mjs +245 -0
  88. package/server/services/remote-relay-registry-service.mjs +52 -0
  89. package/server/services/session-worker-launch-service.mjs +100 -15
  90. package/server/services/session-worker-process-service.mjs +348 -129
  91. package/server/services/session-worker-registry-service.mjs +18 -1
  92. package/server/services/session-worker-stop-service.mjs +24 -13
  93. package/server/services/session-worker-supervisor-service.mjs +56 -1
  94. package/server/services/update-install-service.mjs +9 -3
  95. package/server/services/usage-limit-pause-service.mjs +3 -1
  96. package/server/services/worker-launch-note-service.mjs +151 -0
  97. package/shared/claude-attribution.mjs +117 -0
  98. package/shared/claude-cloud/api-client.mjs +809 -0
  99. package/shared/claude-cloud/base-url.mjs +47 -0
  100. package/shared/claude-cloud/credentials.mjs +221 -0
  101. package/shared/claude-cloud/repo-url.mjs +111 -0
  102. package/shared/provider-routing.mjs +4 -2
  103. package/shared/remote-relay-contract.mjs +153 -22
  104. package/shared/remote-relay-tool-core.mjs +11 -8
  105. package/shared/steer-settle-failure.mjs +1 -0
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ import { execFile as nodeExecFile } from 'child_process';
4
+
5
+ import { normalizeGitHubRepoUrl } from '../../shared/claude-cloud/repo-url.mjs';
6
+ import { createGitChangesService } from './git-changes-service.mjs';
7
+
8
+ // What a folder's git checkout says about where it lives on GitHub: the
9
+ // remote, the branch, and how far the checkout is from what was pushed. The
10
+ // New Chat modal fills the repository and branch of a Claude Cloud chat from
11
+ // it and warns about work the cloud clone will not have (commits that were
12
+ // never pushed, uncommitted changes).
13
+ //
14
+ // Read-only: `git status` and `git remote get-url`, nothing else. The route
15
+ // validates the folder before calling in.
16
+
17
+ const GIT_COMMAND_TIMEOUT_MS = 15_000;
18
+ const DEFAULT_REMOTE = 'origin';
19
+
20
+ const NO_GIT = Object.freeze({
21
+ hasGit: false,
22
+ remoteUrl: null,
23
+ repoUrl: null,
24
+ slug: null,
25
+ branch: null,
26
+ upstream: null,
27
+ ahead: 0,
28
+ behind: 0,
29
+ dirty: false,
30
+ });
31
+
32
+ /**
33
+ * A remote URL as the browser may see it: credentials in front of the host
34
+ * (`https://user:secret@host/…`) are dropped. The scp form keeps its `git@`.
35
+ */
36
+ export function redactRemoteUrlCredentials(remoteUrl) {
37
+ const text = String(remoteUrl || '').trim();
38
+ if (!text) return '';
39
+ if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(text)) return text;
40
+ try {
41
+ const url = new URL(text);
42
+ if (!url.username && !url.password) return text;
43
+ // ssh://git@host/… names the transport user, not a secret.
44
+ if (url.protocol === 'ssh:' && !url.password) return text;
45
+ url.username = '';
46
+ url.password = '';
47
+ return url.toString();
48
+ } catch {
49
+ return text.replace(/\/\/[^/@\s]*@/, '//');
50
+ }
51
+ }
52
+
53
+ /** The remote a tracking ref such as `fork/main` lives on, or ''. */
54
+ function remoteOfUpstream(upstream) {
55
+ const text = String(upstream || '').trim();
56
+ const slash = text.indexOf('/');
57
+ return slash > 0 ? text.slice(0, slash) : '';
58
+ }
59
+
60
+ export function createGitRemoteService({
61
+ execFileImpl = nodeExecFile,
62
+ gitChangesService = createGitChangesService({ execFileImpl }),
63
+ } = {}) {
64
+ function readRemoteUrl(rootPath, remoteName) {
65
+ return new Promise((resolve) => {
66
+ try {
67
+ execFileImpl('git', ['remote', 'get-url', remoteName], {
68
+ cwd: rootPath,
69
+ timeout: GIT_COMMAND_TIMEOUT_MS,
70
+ windowsHide: true,
71
+ }, (error, stdout) => {
72
+ // No such remote is an answer, not a failure.
73
+ resolve(error ? '' : String(stdout || '').trim().split(/\r?\n/)[0] || '');
74
+ });
75
+ } catch {
76
+ resolve('');
77
+ }
78
+ });
79
+ }
80
+
81
+ /**
82
+ * `{ ok, hasGit, remoteUrl, repoUrl, slug, branch, upstream, ahead, behind,
83
+ * dirty }` for a folder; `hasGit: false` when it is not a git checkout.
84
+ */
85
+ async function describe(rootPath) {
86
+ const status = await gitChangesService.getStatus(rootPath);
87
+ if (!status?.ok) return { ok: false, error: status?.error || 'Failed to read git status' };
88
+ if (status.isRepo === false) return { ok: true, ...NO_GIT };
89
+
90
+ const upstream = String(status.upstream || '').trim();
91
+ let remoteUrl = await readRemoteUrl(rootPath, DEFAULT_REMOTE);
92
+ const upstreamRemote = remoteOfUpstream(upstream);
93
+ if (!remoteUrl && upstreamRemote && upstreamRemote !== DEFAULT_REMOTE) {
94
+ remoteUrl = await readRemoteUrl(rootPath, upstreamRemote);
95
+ }
96
+ // One validator for the modal and for the bootstrap that follows it: a
97
+ // remote the bootstrap would refuse is shown as "no GitHub remote".
98
+ const repo = normalizeGitHubRepoUrl(remoteUrl);
99
+ const branch = status.detached ? '' : String(status.branch || '').trim();
100
+ return {
101
+ ok: true,
102
+ hasGit: true,
103
+ remoteUrl: redactRemoteUrlCredentials(remoteUrl) || null,
104
+ repoUrl: repo?.repoUrl || null,
105
+ slug: repo?.slug || null,
106
+ branch: branch || null,
107
+ upstream: upstream || null,
108
+ ahead: Number(status.ahead || 0),
109
+ behind: Number(status.behind || 0),
110
+ dirty: Array.isArray(status.files) && status.files.length > 0,
111
+ };
112
+ }
113
+
114
+ return { describe };
115
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { randomBytes } from 'node:crypto';
7
+ import fs from 'node:fs';
7
8
  import os from 'node:os';
8
9
  import path from 'node:path';
9
10
 
@@ -45,7 +46,7 @@ export function relayUrl({ config, lanAddress = null } = {}) {
45
46
  * the service runs server.js directly with the same env the launcher would set,
46
47
  * so a `gh copilot` session is not tied to the unit's lifetime.
47
48
  */
48
- export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir }) {
49
+ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, logDir, pathEnv = '' }) {
49
50
  // systemd units are Linux-only, so the path inside the unit is always
50
51
  // posix-joined — host-platform path.join would write backslashes when this
51
52
  // template is exercised on Windows (tests; the CLI never writes it there).
@@ -59,6 +60,10 @@ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, lo
59
60
  `Environment=COPILOT_WEB_RELAY_CONFIG=${configPath}`,
60
61
  `Environment=COPILOT_WEB_RELAY_DATA_DIR=${dataDir}`,
61
62
  `Environment=COPILOT_WEB_RELAY_LOG_DIR=${logDir}`,
63
+ // A user service starts with systemd's bare PATH: without this line the
64
+ // relay's sessions find neither the provider CLIs in ~/.local/bin nor, on
65
+ // an install with a private Node, `node` and `npm` themselves.
66
+ ...(pathEnv ? [`Environment="PATH=${escapeSystemdValue(pathEnv)}"`] : []),
62
67
  'Restart=on-failure',
63
68
  'RestartSec=5',
64
69
  '',
@@ -68,6 +73,67 @@ export function buildSystemdUnit({ nodeBin, packageRoot, configPath, dataDir, lo
68
73
  ].join('\n');
69
74
  }
70
75
 
76
+ // Inside a quoted systemd assignment a backslash and a quote need escaping,
77
+ // and `%` opens a specifier.
78
+ function escapeSystemdValue(value) {
79
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%');
80
+ }
81
+
82
+ /**
83
+ * The PATH a relay service gets: the PATH `oar setup` ran with (the user's
84
+ * shell, so their tools), with two directories added when they are not on it:
85
+ * `~/.local/bin`, where the provider CLIs install themselves and which a
86
+ * shell only picks up once it exists, and the directory of the Node that runs
87
+ * the relay.
88
+ */
89
+ export function buildServicePath({ nodeBin, envPath = '', homeDir = '' } = {}) {
90
+ const entries = String(envPath || '').split(':').filter(Boolean);
91
+ const localBin = homeDir ? path.posix.join(String(homeDir), '.local', 'bin') : '';
92
+ if (localBin && !entries.includes(localBin)) entries.unshift(localBin);
93
+ const nodeDir = nodeBin ? path.posix.dirname(String(nodeBin)) : '';
94
+ if (nodeDir && !entries.includes(nodeDir)) entries.push(nodeDir);
95
+ return entries.join(':');
96
+ }
97
+
98
+ /**
99
+ * The dependencies whose install scripts OAR needs (they fetch or build a
100
+ * native binary). npm 12 runs no install script it was not told to allow, and
101
+ * a global install has no package.json of its own to say so; npm 10 and 11
102
+ * accept the flag and run the scripts as before. `npm-install-scripts.test.mjs`
103
+ * holds this list to the lockfile.
104
+ */
105
+ export const NPM_INSTALL_SCRIPT_PACKAGES = Object.freeze(['better-sqlite3', 'cloudflared', 'koffi']);
106
+ export const NPM_ALLOW_SCRIPTS_ARG = `--allow-scripts=${NPM_INSTALL_SCRIPT_PACKAGES.join(',')}`;
107
+
108
+ /**
109
+ * How to run `npm install -g` so the update lands in the install that is
110
+ * running. Away from Windows: the npm that belongs to this Node (a relay
111
+ * service or a private Node has no `npm` on PATH, and a version manager may
112
+ * put another Node's there), this Node first on the child's PATH because npm
113
+ * is a `node` script, and the prefix this package sits in, so an install made
114
+ * with `--prefix` is updated in place.
115
+ */
116
+ export function resolveNpmInvocation({
117
+ execPath = process.execPath,
118
+ packageRoot = '',
119
+ platform = process.platform,
120
+ env = process.env,
121
+ existsImpl = fs.existsSync,
122
+ } = {}) {
123
+ if (platform === 'win32') return { command: 'npm.cmd', prefixArgs: [], env: { ...env } };
124
+ const nodeDir = path.posix.dirname(String(execPath || ''));
125
+ const beside = path.posix.join(nodeDir, 'npm');
126
+ const command = nodeDir && existsImpl(beside) ? beside : 'npm';
127
+ const envPath = String(env?.PATH || '');
128
+ const childPath = envPath.split(':')[0] === nodeDir ? envPath : [nodeDir, envPath].filter(Boolean).join(':');
129
+ const suffix = '/lib/node_modules/@oar-sh/oar';
130
+ const root = String(packageRoot || '').replace(/\/+$/, '');
131
+ const prefixArgs = root.endsWith(suffix) && root.length > suffix.length
132
+ ? ['--prefix', root.slice(0, -suffix.length)]
133
+ : [];
134
+ return { command, prefixArgs, env: { ...env, PATH: childPath } };
135
+ }
136
+
71
137
  /**
72
138
  * Provider CLIs `oar doctor` probes, with the args that answer fast. Cursor is
73
139
  * deliberately absent: the relay drives it through the bundled @cursor/sdk npm
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Claude Cloud usage (provider `claude-cloud`).
5
+ *
6
+ * Cloud sessions are billed to the Claude account: a dollar credit is spent
7
+ * first where the account holds one, and without it the turns count against
8
+ * the same 5-hour and weekly windows the Claude card shows. So this card has
9
+ * no windows of its own. It shows
10
+ * - the dollar buckets of the live account usage (the credit and its expiry),
11
+ * - what the relay's own cloud conversations have cost, as Anthropic reported
12
+ * it per session (the relay keeps the latest figure per conversation; there
13
+ * is no account-wide total to ask for),
14
+ * - the session detail of the latest cloud worker report.
15
+ *
16
+ * The credit arrives under an internal codename that may change, so every
17
+ * top-level bucket of the usage body that carries a dollar limit is a meter;
18
+ * the codename table only supplies a nicer label.
19
+ */
20
+
21
+ import {
22
+ SOURCE_LIVE,
23
+ SOURCE_WORKER,
24
+ STATUS_OK,
25
+ STATUS_PARTIAL,
26
+ STATUS_UNAVAILABLE,
27
+ buildDetailSection,
28
+ buildMeter,
29
+ buildProviderCard,
30
+ pickNonNegative,
31
+ toFiniteNumber,
32
+ toIsoTimestamp,
33
+ toTrimmedString,
34
+ } from './plan-usage-contract.mjs';
35
+ import { CLAUDE_USAGE_URL, buildClaudeSessionSections, formatClaudeMoney } from './plan-usage-claude.mjs';
36
+
37
+ export const CLAUDE_CLOUD_PROVIDER_ID = 'claude-cloud';
38
+ export const CLAUDE_CLOUD_LABEL = 'Claude Cloud';
39
+
40
+ const CLAUDE_CODE_WEB_URL = 'https://claude.ai/code';
41
+
42
+ // Null-prototype: the keys come from an API body.
43
+ const DOLLAR_BUCKET_LABELS = Object.freeze(Object.assign(Object.create(null), {
44
+ iguana_necktie: 'Cloud sessions credit',
45
+ }));
46
+
47
+ /** "maple_sprocket" → "Maple sprocket credit": the label of a bucket the table does not know. */
48
+ export function humanizeDollarBucketKey(key) {
49
+ const words = String(key ?? '').replace(/[^A-Za-z0-9]+/g, ' ').trim().toLowerCase();
50
+ if (!words) return 'Credit';
51
+ return `${words.charAt(0).toUpperCase()}${words.slice(1)} credit`;
52
+ }
53
+
54
+ /**
55
+ * The dollar buckets of an account usage body, in the body's order: every
56
+ * top-level value that is an object with a numeric `limit_dollars`.
57
+ */
58
+ export function listClaudeDollarBuckets(usageBody) {
59
+ if (!usageBody || typeof usageBody !== 'object' || Array.isArray(usageBody)) return [];
60
+ const buckets = [];
61
+ for (const [key, value] of Object.entries(usageBody)) {
62
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
63
+ const limitUsd = toFiniteNumber(value.limit_dollars);
64
+ if (limitUsd === null) continue;
65
+ buckets.push({
66
+ key,
67
+ label: DOLLAR_BUCKET_LABELS[key] || humanizeDollarBucketKey(key),
68
+ limitUsd,
69
+ usedUsd: toFiniteNumber(value.used_dollars),
70
+ remainingUsd: toFiniteNumber(value.remaining_dollars),
71
+ utilization: toFiniteNumber(value.utilization),
72
+ expiresAt: toIsoTimestamp(value.resets_at),
73
+ });
74
+ }
75
+ return buckets;
76
+ }
77
+
78
+ /**
79
+ * What the credit-offer answer says: `{ open, amount }`, where `amount` is
80
+ * the formatted sum when the answer names one. An offer that was granted
81
+ * already is not open.
82
+ */
83
+ export function describeClaudeCreditOffer(offer) {
84
+ if (!offer || typeof offer !== 'object') return { open: false, amount: null };
85
+ const open = (offer.available === true || offer.eligible === true) && offer.granted !== true;
86
+ const minor = toFiniteNumber(offer.amount_minor_units);
87
+ const amount = minor !== null && minor > 0 ? formatClaudeMoney(minor / 100, offer.currency) : null;
88
+ return { open, amount };
89
+ }
90
+
91
+ /**
92
+ * @param {object} options
93
+ * @param {object|null} options.account live account usage (`{ usage, prepaid,
94
+ * offer, fetchedAt, error }`), or null when it was not read
95
+ * @param {object|null} options.spend what the relay recorded: `{ totalUsd,
96
+ * conversationCount, conversation: { costUsd } | null }` (`conversation` is
97
+ * the one the modal was opened from, when it is a cloud conversation)
98
+ * @param {object|null} options.usage the latest `claude-cloud` snapshot
99
+ * @param {string|null} options.capturedAt when that snapshot was stored
100
+ * @param {boolean} options.configured Claude Cloud provider enabled
101
+ */
102
+ export function buildClaudeCloudPlanCard({
103
+ account = null,
104
+ spend = null,
105
+ usage = null,
106
+ capturedAt = null,
107
+ configured = true,
108
+ } = {}) {
109
+ // Opt-in like Grok: no card at all while the provider is off.
110
+ if (!configured) return null;
111
+
112
+ const hasAccount = !!account?.usage && typeof account.usage === 'object';
113
+ const buckets = hasAccount ? listClaudeDollarBuckets(account.usage) : [];
114
+ const meters = buckets.map((bucket) => buildMeter({
115
+ id: `claude-cloud-credit-${bucket.key.replace(/[^A-Za-z0-9_]+/g, '-')}`,
116
+ label: bucket.label,
117
+ unit: 'usd',
118
+ used: bucket.usedUsd,
119
+ allowance: bucket.limitUsd,
120
+ remaining: bucket.remainingUsd,
121
+ // The dollars are exact; the percentage only stands in when they are missing.
122
+ utilization: bucket.usedUsd === null && bucket.remainingUsd === null ? bucket.utilization : null,
123
+ resetAt: bucket.expiresAt,
124
+ resetKind: 'expiry',
125
+ emphasis: 'primary',
126
+ })).filter(Boolean);
127
+ const hasCredit = meters.length > 0;
128
+
129
+ const details = [];
130
+ const conversationCount = Math.max(0, Math.round(toFiniteNumber(spend?.conversationCount) || 0));
131
+ if (conversationCount > 0) {
132
+ const totalUsd = pickNonNegative(spend?.totalUsd) ?? 0;
133
+ const countText = `${conversationCount} cloud conversation${conversationCount === 1 ? '' : 's'}`;
134
+ meters.push(buildMeter({
135
+ id: 'claude-cloud-spend',
136
+ label: 'Cloud spend (OAR sessions)',
137
+ unit: 'usd',
138
+ used: totalUsd,
139
+ emphasis: hasCredit ? 'secondary' : 'primary',
140
+ note: `${countText} on this relay. Anthropic’s reported cost, not a billing statement.`,
141
+ }));
142
+ const rows = [{ label: 'All cloud conversations', value: formatClaudeMoney(totalUsd), hint: countText }];
143
+ if (spend?.conversation && typeof spend.conversation === 'object') {
144
+ const cost = pickNonNegative(spend.conversation.costUsd);
145
+ rows.push({
146
+ label: 'This conversation',
147
+ value: cost === null ? '—' : formatClaudeMoney(cost),
148
+ hint: cost === null ? 'No cost reported yet' : null,
149
+ });
150
+ }
151
+ details.push(buildDetailSection({
152
+ id: 'claude-cloud-spend',
153
+ label: 'Cloud spend (OAR sessions)',
154
+ note: 'The cost Anthropic reports per cloud session, summed over the cloud conversations this relay still has. Not a billing statement; sessions started elsewhere are not included.',
155
+ rows,
156
+ }));
157
+ }
158
+
159
+ details.push(...buildClaudeSessionSections(usage?.session, {
160
+ idPrefix: 'claude-cloud',
161
+ sessionLabel: 'Latest cloud session report',
162
+ modelsLabel: 'By model (latest cloud session report)',
163
+ costLabel: 'Reported cost',
164
+ costHint: 'Anthropic’s reported cost, not a billing statement',
165
+ }));
166
+
167
+ const notes = [];
168
+ const offer = describeClaudeCreditOffer(account?.offer);
169
+ if (!hasCredit && offer.open) {
170
+ notes.push({
171
+ id: 'claude-cloud-credit-offer',
172
+ text: offer.amount
173
+ ? `A cloud credit of ${offer.amount} is available to claim on claude.ai.`
174
+ : 'A cloud credit is available to claim on claude.ai.',
175
+ link: { label: 'Open the usage page', url: CLAUDE_USAGE_URL },
176
+ });
177
+ }
178
+ notes.push({
179
+ id: 'claude-cloud-billing',
180
+ text: 'Cloud turns spend the cloud credit first; without a credit they count against the Claude limits (5-hour and weekly).',
181
+ });
182
+
183
+ const resolvedMeters = meters.filter(Boolean);
184
+ const resolvedDetails = details.filter(Boolean);
185
+ let status = STATUS_OK;
186
+ let message = null;
187
+ if (!hasAccount) {
188
+ status = resolvedMeters.length || resolvedDetails.length ? STATUS_PARTIAL : STATUS_UNAVAILABLE;
189
+ message = ['The account usage could not be read, so a cloud credit is not shown.', toTrimmedString(account?.error)]
190
+ .filter(Boolean)
191
+ .join(' ');
192
+ } else if (!resolvedMeters.length) {
193
+ message = 'This account holds no cloud credit, and no cloud conversation on this relay has run yet.';
194
+ }
195
+
196
+ return buildProviderCard({
197
+ provider: CLAUDE_CLOUD_PROVIDER_ID,
198
+ label: CLAUDE_CLOUD_LABEL,
199
+ status,
200
+ message,
201
+ source: hasAccount ? SOURCE_LIVE : SOURCE_WORKER,
202
+ capturedAt: hasAccount ? (toIsoTimestamp(account.fetchedAt) || capturedAt) : capturedAt,
203
+ meters: resolvedMeters,
204
+ details: resolvedDetails,
205
+ notes,
206
+ links: [
207
+ { label: 'Claude usage settings', url: CLAUDE_USAGE_URL },
208
+ { label: 'Claude Code on the web', url: CLAUDE_CODE_WEB_URL },
209
+ ],
210
+ });
211
+ }