@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,176 @@
1
+ 'use strict';
2
+
3
+ // The Claude account's usage, read live for the "Check Usage" modal.
4
+ //
5
+ // Three reads with the Claude CLI's login, none of which costs a model turn:
6
+ // the usage windows and dollar buckets, the prepaid credits, and whether a
7
+ // usage credit is on offer. The first one is the result; the other two are
8
+ // garnish, and their failure only leaves their field empty.
9
+ //
10
+ // The Claude Cloud switch is the user's consent for the relay to use that
11
+ // login at all, so while it is off nothing is read and nothing that was read
12
+ // before is handed out. Nothing here sees the token: the injected cloud client
13
+ // reads it itself, and the only error text passed on is the client's own
14
+ // (which the client has redacted), with a bearer header taken out once more.
15
+
16
+ import { ClaudeCloudError } from '../../shared/claude-cloud/credentials.mjs';
17
+
18
+ const DEFAULT_CACHE_MS = 60_000;
19
+ // A failed read is kept for a shorter time: long enough that a modal that is
20
+ // opened and refreshed does not ask again at once, short enough that the next
21
+ // look after a hiccup is live.
22
+ const MAX_ERROR_CACHE_MS = 15_000;
23
+ const MAX_ERROR_CHARS = 300;
24
+ const GENERIC_ERROR = 'The Claude account usage could not be read.';
25
+ const TIMEOUT_ERROR = 'The Claude account usage did not arrive in time.';
26
+
27
+ function isPlainObject(value) {
28
+ return !!value && typeof value === 'object' && !Array.isArray(value);
29
+ }
30
+
31
+ /** What the card may show about a failed read; never more than the client's own words. */
32
+ function describeError(error) {
33
+ if (!(error instanceof ClaudeCloudError)) return GENERIC_ERROR;
34
+ const text = String(error.message || '')
35
+ .replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
36
+ .replace(/\s+/g, ' ')
37
+ .trim();
38
+ if (!text) return GENERIC_ERROR;
39
+ return text.length > MAX_ERROR_CHARS ? `${text.slice(0, MAX_ERROR_CHARS)}…` : text;
40
+ }
41
+
42
+ /**
43
+ * `cloudClient`: shared/claude-cloud/api-client.mjs (`getAccountUsage`,
44
+ * `getOrganizationId`, `getPrepaidCredits`, `getCreditGrantOffer`).
45
+ * `isEnabled`: whether the Claude Cloud provider is switched on.
46
+ * `now`: the clock, as a Date or as milliseconds.
47
+ */
48
+ export function createClaudeAccountUsageService({
49
+ cloudClient = null,
50
+ isEnabled = () => false,
51
+ now = () => Date.now(),
52
+ cacheMs = DEFAULT_CACHE_MS,
53
+ } = {}) {
54
+ const keepMs = Number.isFinite(Number(cacheMs)) && Number(cacheMs) > 0 ? Number(cacheMs) : 0;
55
+ let cached = null;
56
+ let inFlight = null;
57
+
58
+ function nowMs() {
59
+ const value = now();
60
+ const ms = value instanceof Date ? value.getTime() : Number(value);
61
+ return Number.isFinite(ms) ? ms : Date.now();
62
+ }
63
+
64
+ function enabled() {
65
+ try {
66
+ return isEnabled() === true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ async function readOptional(method) {
73
+ if (typeof cloudClient?.[method] !== 'function') return null;
74
+ try {
75
+ const body = await cloudClient[method]();
76
+ return isPlainObject(body) ? body : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** The prepaid credits and the offer: both need the organisation, which is asked for once. */
83
+ async function readExtras() {
84
+ if (typeof cloudClient?.getOrganizationId === 'function') {
85
+ try {
86
+ await cloudClient.getOrganizationId();
87
+ } catch {
88
+ return [null, null];
89
+ }
90
+ }
91
+ return Promise.all([readOptional('getPrepaidCredits'), readOptional('getCreditGrantOffer')]);
92
+ }
93
+
94
+ async function readUsage() {
95
+ if (typeof cloudClient?.getAccountUsage !== 'function') {
96
+ return { usage: null, error: GENERIC_ERROR };
97
+ }
98
+ try {
99
+ const body = await cloudClient.getAccountUsage();
100
+ return isPlainObject(body) ? { usage: body, error: null } : { usage: null, error: GENERIC_ERROR };
101
+ } catch (error) {
102
+ return { usage: null, error: describeError(error) };
103
+ }
104
+ }
105
+
106
+ async function fetchAll() {
107
+ const [{ usage, error }, [prepaid, offer]] = await Promise.all([
108
+ readUsage(),
109
+ readExtras().catch(() => [null, null]),
110
+ ]);
111
+ return { usage, prepaid, offer, fetchedAt: new Date(nowMs()).toISOString(), error };
112
+ }
113
+
114
+ function load() {
115
+ if (cached && nowMs() - cached.at < (cached.value.error ? Math.min(keepMs, MAX_ERROR_CACHE_MS) : keepMs)) {
116
+ return Promise.resolve(cached.value);
117
+ }
118
+ if (!inFlight) {
119
+ inFlight = fetchAll()
120
+ .catch(() => ({ usage: null, prepaid: null, offer: null, fetchedAt: new Date(nowMs()).toISOString(), error: GENERIC_ERROR }))
121
+ .then((value) => {
122
+ cached = { at: nowMs(), value };
123
+ return value;
124
+ })
125
+ .finally(() => { inFlight = null; });
126
+ }
127
+ return inFlight;
128
+ }
129
+
130
+ /**
131
+ * Resolves `{ usage, prepaid, offer, fetchedAt, error }`, or null while the
132
+ * provider is off. Never rejects. With `timeoutMs`, a read that takes
133
+ * longer resolves with an `error` and no data; the read itself goes on and
134
+ * fills the cache for the next caller.
135
+ */
136
+ async function getAccountUsage({ timeoutMs = 0 } = {}) {
137
+ if (!enabled()) {
138
+ cached = null;
139
+ return null;
140
+ }
141
+ let value;
142
+ try {
143
+ const pending = load();
144
+ if (Number(timeoutMs) > 0) {
145
+ let timer = null;
146
+ const timeout = new Promise((resolve) => {
147
+ timer = setTimeout(() => resolve({ usage: null, prepaid: null, offer: null, fetchedAt: null, error: TIMEOUT_ERROR }), Number(timeoutMs));
148
+ timer.unref?.();
149
+ });
150
+ value = await Promise.race([pending, timeout]).finally(() => clearTimeout(timer));
151
+ } else {
152
+ value = await pending;
153
+ }
154
+ } catch {
155
+ value = { usage: null, prepaid: null, offer: null, fetchedAt: null, error: GENERIC_ERROR };
156
+ }
157
+ // Switched off while the answer was on its way: it is not handed out.
158
+ if (!enabled()) {
159
+ cached = null;
160
+ return null;
161
+ }
162
+ return value;
163
+ }
164
+
165
+ /**
166
+ * The last answer without a read, however old, or null when there is none
167
+ * (or the provider is off): for a caller that wants the other providers
168
+ * live and this one as it was.
169
+ */
170
+ function peekAccountUsage() {
171
+ if (!enabled()) return null;
172
+ return cached?.value || null;
173
+ }
174
+
175
+ return { getAccountUsage, peekAccountUsage };
176
+ }
@@ -0,0 +1,408 @@
1
+ 'use strict';
2
+
3
+ // The suggestions of the New Chat → Claude Cloud repository and branch fields.
4
+ //
5
+ // Repositories come from two places: Anthropic's list of what the Claude
6
+ // GitHub app can reach (cached for a minute, read with the Claude CLI's login
7
+ // through the injected cloud client) and the repositories of earlier cloud
8
+ // chats on this relay (`conversations.cloud_source_json`, newest first). The
9
+ // two are one list: a recent repository that Anthropic does not list is kept
10
+ // and marked unreachable, so the modal can warn before the first message.
11
+ //
12
+ // Branches come from `git ls-remote --symref <url> HEAD refs/heads/*` (the https URL),
13
+ // run on the host without a shell and without any prompt (an https remote
14
+ // the host cannot read fails at once instead of asking for a password). The
15
+ // environment switch OAR_CLAUDE_CLOUD_BRANCH_LOOKUP=off turns the lookup off
16
+ // altogether; the test harness pins it so that no e2e run talks to GitHub.
17
+ //
18
+ // The Claude Cloud switch is the consent to use the CLI's login, so nothing
19
+ // is asked of Anthropic while the provider is off, and nothing read before is
20
+ // handed out. Git needs no login and is not gated by it.
21
+
22
+ import { execFile as nodeExecFile } from 'child_process';
23
+
24
+ import { ClaudeCloudError } from '../../shared/claude-cloud/credentials.mjs';
25
+ import { isValidBranchName, normalizeGitHubRepoUrl } from '../../shared/claude-cloud/repo-url.mjs';
26
+ import { parseCloudSourceJson } from './claude-cloud-session-service.mjs';
27
+
28
+ export const CLAUDE_CLOUD_BRANCH_LOOKUP_ENV = 'OAR_CLAUDE_CLOUD_BRANCH_LOOKUP';
29
+
30
+ const DEFAULT_CACHE_MS = 60_000;
31
+ const GIT_COMMAND_TIMEOUT_MS = 15_000;
32
+ const GIT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
33
+ const MAX_BRANCHES = 500;
34
+ const MAX_ERROR_CHARS = 300;
35
+ const GENERIC_LIST_ERROR = 'The repositories could not be read from Claude Cloud.';
36
+ const DISABLED_MESSAGE = 'Claude Cloud is switched off.';
37
+ const LOOKUP_OFF_MESSAGE = 'Branch lookup is switched off on this relay.';
38
+
39
+ function isPlainObject(value) {
40
+ return !!value && typeof value === 'object' && !Array.isArray(value);
41
+ }
42
+
43
+ function toText(value) {
44
+ return typeof value === 'string' ? value.trim() : '';
45
+ }
46
+
47
+ /** A time as the sort needs it: milliseconds, or -Infinity for none. */
48
+ function timeOf(iso) {
49
+ const ms = Date.parse(String(iso || ''));
50
+ return Number.isFinite(ms) ? ms : -Infinity;
51
+ }
52
+
53
+ /**
54
+ * Whether branches may be looked up with git: every value of
55
+ * OAR_CLAUDE_CLOUD_BRANCH_LOOKUP but `off`, `0` and `false` says yes.
56
+ */
57
+ export function readBranchLookupSwitch(env = process.env) {
58
+ const value = toText(env?.[CLAUDE_CLOUD_BRANCH_LOOKUP_ENV]).toLowerCase();
59
+ return !['off', '0', 'false', 'no'].includes(value);
60
+ }
61
+
62
+ /**
63
+ * The recent cloud sources as the service takes them, read from the session
64
+ * repository's guarded statement: `[{ repoUrl, branch, updatedAt }]`, newest
65
+ * first, without rows whose JSON names no repository. Empty on a schema
66
+ * without the column.
67
+ */
68
+ export function listRecentCloudSourcesFromStatements(stmts) {
69
+ if (typeof stmts?.listRecentCloudSources?.all !== 'function') return [];
70
+ const out = [];
71
+ for (const row of stmts.listRecentCloudSources.all()) {
72
+ const source = parseCloudSourceJson(row?.cloud_source_json);
73
+ if (!source) continue;
74
+ out.push({ repoUrl: source.repoUrl, branch: source.branch, updatedAt: toText(row?.updated_at) || null });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** What the browser may show about a failed read; never more than the client's own words. */
80
+ function describeListError(error) {
81
+ const code = error instanceof ClaudeCloudError ? toText(error.code) || 'error' : 'error';
82
+ const text = error instanceof ClaudeCloudError
83
+ ? String(error.message || '').replace(/Bearer\s+\S+/gi, 'Bearer [redacted]').replace(/\s+/g, ' ').trim()
84
+ : '';
85
+ const message = text ? (text.length > MAX_ERROR_CHARS ? `${text.slice(0, MAX_ERROR_CHARS)}…` : text) : GENERIC_LIST_ERROR;
86
+ return { code, message };
87
+ }
88
+
89
+ /**
90
+ * What git said about a failed ls-remote: its `fatal:` line when there is
91
+ * one, else its first line, without the prefix and without credentials in
92
+ * front of a host.
93
+ */
94
+ function describeGitFailure(stderr) {
95
+ const lines = String(stderr || '').split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean);
96
+ const line = lines.find((entry) => /^(?:fatal|error):/i.test(entry)) || lines[0] || '';
97
+ const text = line
98
+ .replace(/^(?:fatal|error|remote):\s*/i, '')
99
+ .replace(/\/\/[^/@\s]*@/g, '//')
100
+ .replace(/\s+/g, ' ')
101
+ .trim();
102
+ return text.length > MAX_ERROR_CHARS ? `${text.slice(0, MAX_ERROR_CHARS)}…` : text;
103
+ }
104
+
105
+ /**
106
+ * The repository named by a picker value: a bare `owner/name` (what the list
107
+ * shows) or any form normalizeGitHubRepoUrl takes. Null for anything else.
108
+ */
109
+ export function resolveRepositoryInput(input) {
110
+ const text = String(input ?? '').trim();
111
+ if (!text) return null;
112
+ // Exactly owner/name in GitHub's own characters: an account name has no
113
+ // dot, so a host (`example.com/repo`) never reads as one.
114
+ const bare = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(text);
115
+ return normalizeGitHubRepoUrl(bare ? `https://github.com/${text}` : text);
116
+ }
117
+
118
+ /**
119
+ * The branches in `git ls-remote --symref --heads` output: `{ branches,
120
+ * defaultBranch }`, the default first, the rest in code-point order, at most
121
+ * MAX_BRANCHES names. The default is the `ref: refs/heads/X\tHEAD` line;
122
+ * without one (an older git, a detached HEAD on the remote) it is null.
123
+ */
124
+ export function parseLsRemoteHeads(stdout) {
125
+ const names = new Set();
126
+ let defaultBranch = null;
127
+ for (const rawLine of String(stdout || '').split(/\r?\n/)) {
128
+ const line = rawLine.trim();
129
+ if (!line) continue;
130
+ const symref = line.match(/^ref:\s*refs\/heads\/(\S+)\s+HEAD$/);
131
+ if (symref) {
132
+ if (isValidBranchName(symref[1])) defaultBranch = symref[1];
133
+ continue;
134
+ }
135
+ const head = line.match(/^[0-9a-f]{4,64}\s+refs\/heads\/(.+)$/i);
136
+ if (head && isValidBranchName(head[1])) names.add(head[1]);
137
+ }
138
+ const sorted = [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
139
+ const branches = defaultBranch && names.has(defaultBranch)
140
+ ? [defaultBranch, ...sorted.filter((name) => name !== defaultBranch)]
141
+ : sorted;
142
+ return { branches: branches.slice(0, MAX_BRANCHES), defaultBranch };
143
+ }
144
+
145
+ /**
146
+ * `cloud`: shared/claude-cloud/api-client.mjs (`listRepositories`).
147
+ * `isEnabled`: whether the Claude Cloud provider is switched on.
148
+ * `listRecentCloudSources`: `() => [{ repoUrl, branch, updatedAt }]`, newest
149
+ * first (see listRecentCloudSourcesFromStatements).
150
+ * `gitBranchLookup`: the environment switch, read once at start by default.
151
+ * `execFileImpl`: child_process.execFile, for tests.
152
+ * `now`: the clock, as a Date or as milliseconds.
153
+ */
154
+ export function createClaudeCloudRepoService({
155
+ cloud = null,
156
+ isEnabled = () => false,
157
+ listRecentCloudSources = () => [],
158
+ gitBranchLookup = readBranchLookupSwitch(process.env),
159
+ execFileImpl = nodeExecFile,
160
+ now = () => Date.now(),
161
+ cacheMs = DEFAULT_CACHE_MS,
162
+ logger = console,
163
+ } = {}) {
164
+ const keepMs = Number.isFinite(Number(cacheMs)) && Number(cacheMs) > 0 ? Number(cacheMs) : 0;
165
+ let cached = null;
166
+ let inFlight = null;
167
+
168
+ function nowMs() {
169
+ const value = now();
170
+ const ms = value instanceof Date ? value.getTime() : Number(value);
171
+ return Number.isFinite(ms) ? ms : Date.now();
172
+ }
173
+
174
+ function enabled() {
175
+ try {
176
+ return isEnabled() === true;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ /** The recent sources, one per repository (the newest chat wins), newest first. */
183
+ function recentRepositories() {
184
+ let sources = [];
185
+ try {
186
+ sources = listRecentCloudSources();
187
+ } catch (error) {
188
+ logger?.warn?.(`[claude-cloud] recent cloud sources could not be read: ${error?.message || error}`);
189
+ sources = [];
190
+ }
191
+ const bySlug = new Map();
192
+ for (const source of Array.isArray(sources) ? sources : []) {
193
+ const repo = normalizeGitHubRepoUrl(source?.repoUrl);
194
+ if (!repo) continue;
195
+ const key = repo.slug.toLowerCase();
196
+ const recentAt = toText(source?.updatedAt) || null;
197
+ const known = bySlug.get(key);
198
+ if (known && timeOf(known.recentAt) >= timeOf(recentAt)) continue;
199
+ bySlug.set(key, { ...repo, recentAt });
200
+ }
201
+ return [...bySlug.values()].sort((a, b) => timeOf(b.recentAt) - timeOf(a.recentAt));
202
+ }
203
+
204
+ /**
205
+ * Anthropic's list and the recent repositories as one list: recent ones
206
+ * first by their last chat, then the rest by their last push. `accessible`
207
+ * is whether Anthropic lists the repository, or null when its list is not
208
+ * at hand (`listed` null).
209
+ */
210
+ function unite(listed) {
211
+ const recent = recentRepositories();
212
+ const recentBySlug = new Map(recent.map((repo) => [repo.slug.toLowerCase(), repo]));
213
+ const out = [];
214
+ const seen = new Set();
215
+ for (const repo of Array.isArray(listed) ? listed : []) {
216
+ const key = String(repo.slug || '').toLowerCase();
217
+ if (!key || seen.has(key)) continue;
218
+ seen.add(key);
219
+ out.push({
220
+ slug: repo.slug,
221
+ owner: repo.owner,
222
+ name: repo.name,
223
+ repoUrl: repo.repoUrl,
224
+ defaultBranch: repo.defaultBranch ?? null,
225
+ private: repo.private === true,
226
+ archived: repo.archived === true,
227
+ pushedAt: repo.pushedAt ?? null,
228
+ description: repo.description ?? null,
229
+ recentAt: recentBySlug.get(key)?.recentAt ?? null,
230
+ accessible: true,
231
+ });
232
+ }
233
+ for (const repo of recent) {
234
+ const key = repo.slug.toLowerCase();
235
+ if (seen.has(key)) continue;
236
+ seen.add(key);
237
+ out.push({
238
+ slug: repo.slug,
239
+ owner: repo.owner,
240
+ name: repo.repo,
241
+ repoUrl: repo.repoUrl,
242
+ defaultBranch: null,
243
+ private: null,
244
+ archived: null,
245
+ pushedAt: null,
246
+ description: null,
247
+ recentAt: repo.recentAt,
248
+ accessible: listed ? false : null,
249
+ });
250
+ }
251
+ out.sort((a, b) => {
252
+ if (a.recentAt && b.recentAt) return timeOf(b.recentAt) - timeOf(a.recentAt);
253
+ if (a.recentAt || b.recentAt) return a.recentAt ? -1 : 1;
254
+ return timeOf(b.pushedAt) - timeOf(a.pushedAt);
255
+ });
256
+ return out;
257
+ }
258
+
259
+ async function fetchList() {
260
+ if (typeof cloud?.listRepositories !== 'function') throw new Error('The cloud client is unavailable.');
261
+ const listed = await cloud.listRepositories();
262
+ return {
263
+ repos: Array.isArray(listed?.repos) ? listed.repos : [],
264
+ complete: listed?.complete !== false,
265
+ fetchedAt: new Date(nowMs()).toISOString(),
266
+ };
267
+ }
268
+
269
+ /** Anthropic's list, from the cache when it is fresh enough; rejects when the read fails. */
270
+ function loadList({ force }) {
271
+ if (!force && cached && nowMs() - cached.at < keepMs) return Promise.resolve(cached.value);
272
+ if (!inFlight) {
273
+ inFlight = fetchList()
274
+ .then((value) => {
275
+ cached = { at: nowMs(), value };
276
+ return value;
277
+ })
278
+ .finally(() => { inFlight = null; });
279
+ }
280
+ return inFlight;
281
+ }
282
+
283
+ /**
284
+ * GET /api/claude-cloud/repos. Resolves `{ ok, repos, complete, fetchedAt,
285
+ * source, error }`: `ok: true` with Anthropic's list united with the recent
286
+ * repositories (`error` set when the read failed and the list is the
287
+ * cached one), `ok: false` with the recent ones alone when nothing is
288
+ * cached, `ok: false` with `claude_cloud_disabled` while the provider is
289
+ * off. Never rejects.
290
+ */
291
+ async function listRepositories({ force = false } = {}) {
292
+ if (!enabled()) {
293
+ cached = null;
294
+ return {
295
+ ok: false,
296
+ repos: [],
297
+ complete: false,
298
+ fetchedAt: null,
299
+ source: null,
300
+ error: { code: 'claude_cloud_disabled', message: DISABLED_MESSAGE },
301
+ };
302
+ }
303
+ let value = null;
304
+ let error = null;
305
+ try {
306
+ value = await loadList({ force: force === true });
307
+ } catch (caught) {
308
+ error = describeListError(caught);
309
+ value = cached?.value || null;
310
+ logger?.warn?.(`[claude-cloud] repository list failed: ${error.code}${value ? ' (serving the cached list)' : ''}`);
311
+ }
312
+ // Switched off while the answer was on its way: it is not handed out.
313
+ if (!enabled()) {
314
+ cached = null;
315
+ return {
316
+ ok: false,
317
+ repos: [],
318
+ complete: false,
319
+ fetchedAt: null,
320
+ source: null,
321
+ error: { code: 'claude_cloud_disabled', message: DISABLED_MESSAGE },
322
+ };
323
+ }
324
+ if (!value) {
325
+ return { ok: false, repos: unite(null), complete: false, fetchedAt: null, source: 'recent', error };
326
+ }
327
+ return {
328
+ ok: true,
329
+ repos: unite(value.repos),
330
+ complete: value.complete,
331
+ fetchedAt: value.fetchedAt,
332
+ source: 'anthropic',
333
+ error,
334
+ };
335
+ }
336
+
337
+ function runLsRemote(repoUrl) {
338
+ return new Promise((resolve) => {
339
+ const env = {
340
+ ...process.env,
341
+ // No prompt of any kind: a remote the host cannot read is an answer.
342
+ GIT_TERMINAL_PROMPT: '0',
343
+ // An askpass that prints nothing (echo is on every platform's PATH):
344
+ // git takes the empty answer and fails the authentication at once.
345
+ GIT_ASKPASS: 'echo',
346
+ SSH_ASKPASS: 'echo',
347
+ GCM_INTERACTIVE: 'never',
348
+ GIT_SSH_COMMAND: 'ssh -oBatchMode=yes',
349
+ };
350
+ try {
351
+ // HEAD is named explicitly: `--heads` alone leaves out the symref
352
+ // line that says which branch is the default.
353
+ execFileImpl('git', ['ls-remote', '--symref', repoUrl, 'HEAD', 'refs/heads/*'], {
354
+ env,
355
+ timeout: GIT_COMMAND_TIMEOUT_MS,
356
+ maxBuffer: GIT_MAX_OUTPUT_BYTES,
357
+ windowsHide: true,
358
+ }, (error, stdout, stderr) => {
359
+ resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') });
360
+ });
361
+ } catch (error) {
362
+ resolve({ error, stdout: '', stderr: '' });
363
+ }
364
+ });
365
+ }
366
+
367
+ /**
368
+ * GET /api/claude-cloud/branches. `repoInput` is `owner/name` or a GitHub
369
+ * URL. Resolves `{ ok: true, slug, repoUrl, branches, defaultBranch }` or
370
+ * `{ ok: false, slug, error: { code, message } }` with `invalid_repo`,
371
+ * `disabled`, `timeout` or `unreachable`. Never rejects.
372
+ */
373
+ async function listBranches(repoInput) {
374
+ const repo = resolveRepositoryInput(repoInput);
375
+ if (!repo) {
376
+ return {
377
+ ok: false,
378
+ slug: null,
379
+ error: { code: 'invalid_repo', message: 'A GitHub repository (owner/name or its URL) is required.' },
380
+ };
381
+ }
382
+ if (gitBranchLookup === false || gitBranchLookup === 'off') {
383
+ return { ok: false, slug: repo.slug, error: { code: 'disabled', message: LOOKUP_OFF_MESSAGE } };
384
+ }
385
+ const { error, stdout, stderr } = await runLsRemote(repo.repoUrl);
386
+ if (error) {
387
+ // execFile reports its own timeout as a killed child without an exit code.
388
+ const timedOut = error.killed === true && (error.code === null || error.code === undefined || error.signal === 'SIGTERM');
389
+ if (timedOut) {
390
+ return {
391
+ ok: false,
392
+ slug: repo.slug,
393
+ error: { code: 'timeout', message: `GitHub did not answer within ${Math.round(GIT_COMMAND_TIMEOUT_MS / 1000)} s.` },
394
+ };
395
+ }
396
+ const said = describeGitFailure(stderr) || (error.code === 'ENOENT' ? 'git is not installed on this host.' : '');
397
+ return {
398
+ ok: false,
399
+ slug: repo.slug,
400
+ error: { code: 'unreachable', message: said || `The branches of ${repo.slug} could not be read.` },
401
+ };
402
+ }
403
+ const { branches, defaultBranch } = parseLsRemoteHeads(stdout);
404
+ return { ok: true, slug: repo.slug, repoUrl: repo.repoUrl, branches, defaultBranch };
405
+ }
406
+
407
+ return { listRepositories, listBranches };
408
+ }