@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -0,0 +1,47 @@
1
+ // Where the cloud client sends its calls.
2
+ //
3
+ // Always the Anthropic API, with one exception for tests: the environment
4
+ // variable below may point the client at a fake API on the same machine. The
5
+ // client sends the Claude login as a bearer token to whatever its base URL
6
+ // is, so an environment variable must never be able to name another host: a
7
+ // value that is not a plain http(s) URL of the loopback interface is ignored
8
+ // and the default stays. The relay's client and the cloud worker's both ask
9
+ // here, so the rule lives in this one function.
10
+
11
+ import { CLAUDE_CLOUD_BASE_URL } from './api-client.mjs';
12
+
13
+ export const CLAUDE_CLOUD_BASE_URL_ENV = 'OAR_CLAUDE_CLOUD_API_BASE_URL';
14
+
15
+ // Exactly these, as the URL parser writes them (it lower-cases the name and
16
+ // puts the IPv6 address in brackets). No "anything in 127/8", no trailing dot,
17
+ // no name that merely starts or ends like one of them.
18
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', '[::1]', 'localhost']);
19
+
20
+ /**
21
+ * The origin of a loopback http(s) URL (`http://127.0.0.1:4010`), or null for
22
+ * anything else: another host, another scheme, a URL with a user or a
23
+ * password in it, or text that is no URL at all. A path, query or fragment is
24
+ * dropped: the client's routes start at the root.
25
+ */
26
+ export function loopbackBaseUrlOrNull(value) {
27
+ const text = typeof value === 'string' ? value.trim() : '';
28
+ if (!text) return null;
29
+ let url;
30
+ try {
31
+ url = new URL(text);
32
+ } catch {
33
+ return null;
34
+ }
35
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
36
+ if (url.username || url.password) return null;
37
+ if (!LOOPBACK_HOSTNAMES.has(url.hostname)) return null;
38
+ return url.origin;
39
+ }
40
+
41
+ /**
42
+ * The base URL for `createClaudeCloudClient`: the loopback URL named by
43
+ * `OAR_CLAUDE_CLOUD_API_BASE_URL`, else the Anthropic API.
44
+ */
45
+ export function resolveClaudeCloudBaseUrl(env = process.env) {
46
+ return loopbackBaseUrlOrNull(env?.[CLAUDE_CLOUD_BASE_URL_ENV]) || CLAUDE_CLOUD_BASE_URL;
47
+ }
@@ -0,0 +1,221 @@
1
+ // The Claude login a cloud session runs on.
2
+ //
3
+ // Claude Cloud sessions are created with the claude.ai login the Claude CLI
4
+ // keeps in `<CLAUDE_CONFIG_DIR | ~/.claude>/.credentials.json`. This module is
5
+ // the only place that reads it. It hands out the access token, says what it
6
+ // found (never the token itself), and takes the token back out of any text
7
+ // that is about to leave the process.
8
+ //
9
+ // What it never does: use the refresh token. A refresh from here would rotate
10
+ // the login under the CLI and log it out. When the token has run out the file
11
+ // is read again (the CLI rewrites it while it works), then the caller's
12
+ // `nudge` may run the CLI once so that it refreshes its own login, and after
13
+ // that the answer is `login_expired`.
14
+ //
15
+ // Nothing here logs, and no error it throws carries the token.
16
+
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+
21
+ export const CLAUDE_CLOUD_ERROR_CODES = Object.freeze([
22
+ 'login_missing',
23
+ 'login_expired',
24
+ 'github_not_connected',
25
+ 'repo_access_denied',
26
+ 'environment_missing',
27
+ 'not_found',
28
+ 'rate_limited',
29
+ 'bad_request',
30
+ 'transient',
31
+ ]);
32
+
33
+ const DEFAULT_ERROR_MESSAGES = Object.freeze({
34
+ login_missing: 'No Claude login was found on this machine. Log in with the Claude CLI first.',
35
+ login_expired: 'The Claude login has expired or was rejected. Log in to Claude again.',
36
+ github_not_connected: 'GitHub is not connected to this Claude account.',
37
+ repo_access_denied: 'The Claude GitHub app has no access to this repository.',
38
+ environment_missing: 'The Claude Cloud environment was not found.',
39
+ not_found: 'Claude Cloud did not find the session or resource.',
40
+ rate_limited: 'Claude Cloud is rate limiting this account. Try again later.',
41
+ bad_request: 'Claude Cloud refused the request.',
42
+ transient: 'Claude Cloud is not answering right now.',
43
+ });
44
+
45
+ /** The plain sentence for an error code; the fallback of every ClaudeCloudError. */
46
+ export function claudeCloudErrorMessage(code) {
47
+ return DEFAULT_ERROR_MESSAGES[code] || DEFAULT_ERROR_MESSAGES.transient;
48
+ }
49
+
50
+ /**
51
+ * Every failure of the cloud client and of the credentials: `code` (one of
52
+ * CLAUDE_CLOUD_ERROR_CODES), `status` (the HTTP status, or null when no answer
53
+ * was involved) and `detail` (what the API said, already redacted, or null).
54
+ *
55
+ * `new ClaudeCloudError(code, message?, { status, detail })`; also accepted:
56
+ * `(message, { code, … })` and `({ code, message, … })`.
57
+ */
58
+ export class ClaudeCloudError extends Error {
59
+ constructor(code, message, options) {
60
+ let fields;
61
+ if (code && typeof code === 'object') fields = code;
62
+ else if (message && typeof message === 'object') {
63
+ fields = message.code ? { ...message, message: code } : { ...message, code };
64
+ } else fields = { ...(options || {}), code, message };
65
+ const errorCode = String(fields.code || 'transient');
66
+ super(String(fields.message || '') || claudeCloudErrorMessage(errorCode));
67
+ this.name = 'ClaudeCloudError';
68
+ this.code = errorCode;
69
+ this.status = Number.isFinite(fields.status) ? fields.status : null;
70
+ this.detail = fields.detail ? String(fields.detail) : null;
71
+ }
72
+ }
73
+
74
+ export const CLAUDE_CLOUD_TOKEN_ENV = 'CLAUDE_CODE_OAUTH_TOKEN';
75
+ /** The cached token is trusted until this long before it runs out. */
76
+ export const CLAUDE_CLOUD_TOKEN_MARGIN_MS = 60_000;
77
+ /** The CLI is asked to refresh its login at most this often. */
78
+ export const CLAUDE_CLOUD_NUDGE_INTERVAL_MS = 5 * 60_000;
79
+
80
+ const REDACTED = '[redacted]';
81
+ /** Tokens handed out earlier stay redactable after the file moved on. */
82
+ const MAX_REMEMBERED_TOKENS = 16;
83
+
84
+ /** Where the CLI keeps its login: `CLAUDE_CONFIG_DIR`, else `.claude` in the home directory. */
85
+ export function resolveClaudeCredentialsPath({ env = process.env, homedir = os.homedir, pathImpl = path } = {}) {
86
+ const configDir = String(env?.CLAUDE_CONFIG_DIR || '').trim();
87
+ if (configDir) return pathImpl.join(configDir, '.credentials.json');
88
+ const home = String((typeof homedir === 'function' ? homedir() : homedir) || '').trim();
89
+ return pathImpl.join(home, '.claude', '.credentials.json');
90
+ }
91
+
92
+ function textOrNull(value) {
93
+ const text = typeof value === 'string' ? value.trim() : '';
94
+ return text || null;
95
+ }
96
+
97
+ function isoOrNull(ms) {
98
+ if (ms === null) return null;
99
+ const date = new Date(ms);
100
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
101
+ }
102
+
103
+ /**
104
+ * `nudge`: an async function that makes the Claude CLI refresh its own login
105
+ * (the caller decides which command). Called at most once per five minutes,
106
+ * and only when the login in the file is about to run out or has.
107
+ */
108
+ export function createClaudeCloudCredentials({
109
+ env = process.env,
110
+ homedir = os.homedir,
111
+ fsImpl = fs,
112
+ now = Date.now,
113
+ nudge = null,
114
+ pathImpl = path,
115
+ } = {}) {
116
+ let cached = null;
117
+ let loading = null;
118
+ let lastNudgeAt = null;
119
+ const remembered = [];
120
+
121
+ function remember(token) {
122
+ if (!token || remembered.includes(token)) return;
123
+ remembered.push(token);
124
+ if (remembered.length > MAX_REMEMBERED_TOKENS) remembered.shift();
125
+ }
126
+
127
+ function readEnvToken() {
128
+ const token = textOrNull(env?.[CLAUDE_CLOUD_TOKEN_ENV]);
129
+ if (token) remember(token);
130
+ return token;
131
+ }
132
+
133
+ /** The login in the file, or null when there is no file, no JSON or no token in it. */
134
+ function readFileLogin() {
135
+ let oauth = null;
136
+ try {
137
+ const filePath = resolveClaudeCredentialsPath({ env, homedir, pathImpl });
138
+ oauth = JSON.parse(fsImpl.readFileSync(filePath, 'utf8'))?.claudeAiOauth;
139
+ } catch {
140
+ return null;
141
+ }
142
+ const accessToken = textOrNull(oauth?.accessToken);
143
+ if (!accessToken) return null;
144
+ remember(accessToken);
145
+ const expiresAt = Number(oauth.expiresAt);
146
+ return {
147
+ accessToken,
148
+ expiresAt: Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : null,
149
+ subscriptionType: textOrNull(oauth.subscriptionType),
150
+ rateLimitTier: textOrNull(oauth.rateLimitTier),
151
+ };
152
+ }
153
+
154
+ // A login without an expiry time is taken as it is and read again each time.
155
+ const isExpired = (login) => login.expiresAt !== null && now() >= login.expiresAt;
156
+ const isFresh = (login) => login.expiresAt !== null && now() < login.expiresAt - CLAUDE_CLOUD_TOKEN_MARGIN_MS;
157
+ const needsRefresh = (login) => login.expiresAt !== null && !isFresh(login);
158
+
159
+ async function loadFromFile() {
160
+ let login = readFileLogin();
161
+ if (login && needsRefresh(login) && typeof nudge === 'function'
162
+ && (lastNudgeAt === null || now() - lastNudgeAt >= CLAUDE_CLOUD_NUDGE_INTERVAL_MS)) {
163
+ lastNudgeAt = now();
164
+ // A nudge that fails changes nothing: the file decides.
165
+ try { await nudge(); } catch {}
166
+ login = readFileLogin();
167
+ }
168
+ cached = null;
169
+ if (!login) throw new ClaudeCloudError('login_missing');
170
+ if (isExpired(login)) throw new ClaudeCloudError('login_expired');
171
+ // Inside the last minute the token still works, so it is handed out, but
172
+ // `isFresh` keeps it from being served from the cache.
173
+ cached = login;
174
+ return login.accessToken;
175
+ }
176
+
177
+ /**
178
+ * The bearer token. `forceReload` skips the cache (the API refused the
179
+ * cached token: the CLI may have replaced it early). Throws ClaudeCloudError
180
+ * `login_missing` or `login_expired`.
181
+ */
182
+ async function getAccessToken({ forceReload = false } = {}) {
183
+ const envToken = readEnvToken();
184
+ if (envToken) return envToken;
185
+ if (!forceReload && cached && isFresh(cached)) return cached.accessToken;
186
+ // One read (and one nudge) for everybody who asks while it runs.
187
+ if (!loading) loading = loadFromFile().finally(() => { loading = null; });
188
+ return loading;
189
+ }
190
+
191
+ /** What there is to say about the login without saying the token. Never throws. */
192
+ function describe() {
193
+ const none = { source: 'none', hasToken: false, expiresAt: null, subscriptionType: null, rateLimitTier: null, expired: false };
194
+ if (readEnvToken()) return { ...none, source: 'env', hasToken: true };
195
+ const login = readFileLogin();
196
+ if (!login) return none;
197
+ return {
198
+ source: 'file',
199
+ hasToken: true,
200
+ expiresAt: isoOrNull(login.expiresAt),
201
+ subscriptionType: login.subscriptionType,
202
+ rateLimitTier: login.rateLimitTier,
203
+ expired: isExpired(login),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * The text without any token this module has handed out or seen, and
209
+ * without anything that has the shape of a bearer header or a Claude token.
210
+ */
211
+ function redact(text) {
212
+ let out = String(text ?? '');
213
+ readEnvToken();
214
+ for (const token of remembered) out = out.split(token).join(REDACTED);
215
+ return out
216
+ .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${REDACTED}`)
217
+ .replace(/\bsk-ant-[A-Za-z0-9_-]{8,}/g, REDACTED);
218
+ }
219
+
220
+ return { getAccessToken, describe, redact };
221
+ }
@@ -0,0 +1,111 @@
1
+ // Repository, branch and model strings for Claude Cloud sessions.
2
+ //
3
+ // A cloud session clones a GitHub repository by its https URL and, when one is
4
+ // named, checks out a branch. Both arrive as free text (the New Chat fields,
5
+ // an `origin` remote read from a folder), so everything is brought to one form
6
+ // here before it is stored or sent: the server validates with these, the
7
+ // browser shows what they return, the client sends it.
8
+ //
9
+ // Pure functions, no I/O.
10
+
11
+ const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']);
12
+ // GitHub's own rules: an account name is letters, digits and single hyphens
13
+ // (never leading), at most 39 characters; a repository name is letters,
14
+ // digits, `.`, `_` and `-`, at most 100.
15
+ const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
16
+ const REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
17
+ const MAX_BRANCH_LENGTH = 255;
18
+
19
+ function ownerAndRepoFromPath(pathText) {
20
+ const parts = String(pathText || '').split('/').filter(Boolean);
21
+ // Exactly owner/repo: a pasted page URL (…/tree/main, /orgs/<name>/…) would
22
+ // otherwise be read as some other repository.
23
+ if (parts.length !== 2) return null;
24
+ const owner = parts[0];
25
+ const repo = parts[1].replace(/\.git$/i, '');
26
+ if (!OWNER_PATTERN.test(owner) || owner.endsWith('-')) return null;
27
+ if (!REPO_PATTERN.test(repo) || repo === '.' || repo === '..') return null;
28
+ return { owner, repo };
29
+ }
30
+
31
+ /**
32
+ * The canonical https form of a GitHub repository reference, or null when the
33
+ * text is not one. Accepts `https://github.com/owner/repo` (with `.git`, a
34
+ * trailing slash, or credentials in front of the host, which are dropped),
35
+ * `git@github.com:owner/repo.git`, `ssh://git@github.com/owner/repo`,
36
+ * `git://…` and the scheme-less `github.com/owner/repo`.
37
+ */
38
+ export function normalizeGitHubRepoUrl(input) {
39
+ const text = String(input ?? '').trim();
40
+ if (!text || /\s/.test(text)) return null;
41
+
42
+ let host = '';
43
+ let pathText = '';
44
+ // scp-like: [user@]host:owner/repo(.git). A scheme is followed by `//`, so
45
+ // `https://…` and `ssh://…` never match here.
46
+ const scp = text.match(/^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+):(?!\/\/)(.+)$/);
47
+ if (scp) {
48
+ host = scp[1];
49
+ pathText = scp[2];
50
+ } else {
51
+ const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(text) ? text : `https://${text}`;
52
+ let url;
53
+ try {
54
+ url = new URL(withScheme);
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (!['https:', 'http:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol)) return null;
59
+ host = url.hostname;
60
+ pathText = url.pathname;
61
+ }
62
+
63
+ if (!GITHUB_HOSTS.has(host.toLowerCase())) return null;
64
+ const parsed = ownerAndRepoFromPath(pathText);
65
+ if (!parsed) return null;
66
+ const slug = `${parsed.owner}/${parsed.repo}`;
67
+ return { repoUrl: `https://github.com/${slug}`, owner: parsed.owner, repo: parsed.repo, slug };
68
+ }
69
+
70
+ /**
71
+ * Whether git would accept the text as a branch name (the rules of
72
+ * `git check-ref-format --branch`). Nothing is trimmed: a name with a space
73
+ * around it is not a branch name.
74
+ */
75
+ export function isValidBranchName(value) {
76
+ if (typeof value !== 'string') return false;
77
+ if (!value || value.length > MAX_BRANCH_LENGTH) return false;
78
+ if (value === '@' || value.startsWith('-')) return false;
79
+ // Control characters, space, and the characters git reserves for revisions.
80
+ if (/[\u0000- \u007f~^:?*[\\]/.test(value)) return false;
81
+ if (value.includes('..') || value.includes('@{') || value.includes('//')) return false;
82
+ if (value.startsWith('/') || value.endsWith('/') || value.endsWith('.')) return false;
83
+ return value.split('/').every((part) => !part.startsWith('.') && !part.endsWith('.lock'));
84
+ }
85
+
86
+ /**
87
+ * A model id without its tier suffix: "claude-opus-5[1m]" → "claude-opus-5".
88
+ * The cloud takes plain ids only.
89
+ */
90
+ export function stripModelTierSuffix(model) {
91
+ return String(model ?? '').trim().replace(/\s*\[[^\]]*\]$/, '').trim();
92
+ }
93
+
94
+ function encodeRefForUrl(ref) {
95
+ return ref.split('/').map((part) => encodeURIComponent(part)).join('/');
96
+ }
97
+
98
+ /**
99
+ * Where GitHub shows what a pushed branch changed: the compare page against
100
+ * `base`, the compare page against the default branch when no base is known,
101
+ * and the branch itself when it is the base (a compare of a branch with itself
102
+ * is empty). Null when the slug or the branch is not usable.
103
+ */
104
+ export function compareUrl(slug, base, branch) {
105
+ const repo = normalizeGitHubRepoUrl(`https://github.com/${String(slug ?? '').trim()}`);
106
+ if (!repo || !isValidBranchName(branch)) return null;
107
+ const head = encodeRefForUrl(branch);
108
+ if (base === branch) return `${repo.repoUrl}/tree/${head}`;
109
+ if (!isValidBranchName(base)) return `${repo.repoUrl}/compare/${head}`;
110
+ return `${repo.repoUrl}/compare/${encodeRefForUrl(base)}...${head}`;
111
+ }
@@ -0,0 +1,257 @@
1
+ // The Claude subscription's usage limit, as the CLI reports it.
2
+ //
3
+ // The CLI sends a `rate_limit_event` at the start of every turn of a
4
+ // claude.ai subscription session. At the limit it refuses the request without
5
+ // waiting or retrying (captured live, CLI 2.1.283 / SDK 0.3.283):
6
+ //
7
+ // rate_limit_event { status: 'rejected', rateLimitType: 'five_hour',
8
+ // resetsAt: <unix seconds>, unifiedWindows: {...} }
9
+ // assistant { model: '<synthetic>', error: 'rate_limit',
10
+ // is_api_error_message: true, text: "You've hit your
11
+ // session limit · resets 10pm (UTC)" }
12
+ // result { is_error: true, subtype: 'success',
13
+ // api_error_status: 429, terminal_reason: 'api_error' }
14
+ //
15
+ // Shared by the Claude worker (which sees the stream) and the relay (which
16
+ // pauses the turn and resumes it at the reset).
17
+
18
+ export const USAGE_LIMIT_CODE = 'usage-limit';
19
+ export const USAGE_LIMIT_STABLE_CODE = `claude.${USAGE_LIMIT_CODE}`;
20
+ export const USAGE_LIMIT_KIND = 'claude-usage-limit';
21
+
22
+ // A wait up to this long resumes by itself. A longer one (a weekly window can
23
+ // be days away) stays paused until the user resumes it.
24
+ export const USAGE_LIMIT_AUTO_RESUME_MAX_MS = 6 * 60 * 60 * 1000;
25
+
26
+ // The reset time is the server's; the first request after it must not race
27
+ // the window's own bookkeeping.
28
+ export const USAGE_LIMIT_RESUME_MARGIN_MS = 60 * 1000;
29
+
30
+ // A rejected report that names no reset still ahead counts for this long
31
+ // after it arrived. Right after a reset the CLI can refuse once more and name
32
+ // the reset that has just passed.
33
+ export const USAGE_LIMIT_REPORT_FRESH_MS = 10 * 60 * 1000;
34
+
35
+ const STATUSES = new Set(['allowed', 'allowed_warning', 'rejected']);
36
+
37
+ const WINDOW_LABELS = Object.freeze({
38
+ five_hour: '5-hour limit',
39
+ seven_day: 'weekly limit',
40
+ seven_day_opus: 'weekly Opus limit',
41
+ seven_day_sonnet: 'weekly Sonnet limit',
42
+ seven_day_overage_included: 'weekly limit',
43
+ overage: 'extra usage limit',
44
+ });
45
+
46
+ const LIMIT_TEXT = /\b(?:hit|reached)\s+your\s+(?:\w+\s+){0,3}limit\b|\busage limit\b/i;
47
+
48
+ function toFraction(value) {
49
+ const n = Number(value);
50
+ if (!Number.isFinite(n) || n < 0) return null;
51
+ return Math.min(n, 1);
52
+ }
53
+
54
+ // Unix seconds (what the CLI sends), milliseconds or an ISO string.
55
+ function toIso(value) {
56
+ if (value === null || value === undefined || value === '') return null;
57
+ let ms = NaN;
58
+ if (typeof value === 'number' || /^\d+(\.\d+)?$/.test(String(value).trim())) {
59
+ const n = Number(value);
60
+ ms = n < 1e11 ? n * 1000 : n;
61
+ } else {
62
+ ms = Date.parse(String(value));
63
+ }
64
+ return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null;
65
+ }
66
+
67
+ function normalizeWindows(raw) {
68
+ if (!raw || typeof raw !== 'object') return [];
69
+ return Object.entries(raw)
70
+ .map(([id, window]) => ({
71
+ id: String(id),
72
+ utilization: toFraction(window?.utilization),
73
+ resetsAt: toIso(window?.resetsAt ?? window?.resets_at),
74
+ }))
75
+ .filter((window) => window.utilization !== null || window.resetsAt);
76
+ }
77
+
78
+ export function usageLimitWindowLabel(rateLimitType) {
79
+ return WINDOW_LABELS[String(rateLimitType || '').trim()] || 'usage limit';
80
+ }
81
+
82
+ /**
83
+ * The `rate_limit_info` of a `rate_limit_event`, in the shape the relay keeps.
84
+ * Returns null for anything that is not one.
85
+ */
86
+ export function normalizeRateLimitInfo(raw, { now = Date.now() } = {}) {
87
+ if (!raw || typeof raw !== 'object') return null;
88
+ const status = String(raw.status || '').trim();
89
+ if (!STATUSES.has(status)) return null;
90
+ const rateLimitType = String(raw.rateLimitType || '').trim() || null;
91
+ const windows = normalizeWindows(raw.unifiedWindows);
92
+ const own = windows.find((window) => window.id === rateLimitType) || null;
93
+ return {
94
+ status,
95
+ rateLimitType,
96
+ resetsAt: toIso(raw.resetsAt) || own?.resetsAt || null,
97
+ // A rejected event carries no utilization of its own: the window is full.
98
+ utilization: toFraction(raw.utilization) ?? own?.utilization ?? (status === 'rejected' ? 1 : null),
99
+ surpassedThreshold: toFraction(raw.surpassedThreshold),
100
+ overageStatus: STATUSES.has(String(raw.overageStatus || '')) ? String(raw.overageStatus) : null,
101
+ isUsingOverage: raw.isUsingOverage === true || raw.overageInUse === true,
102
+ windows,
103
+ observedAt: new Date(now).toISOString(),
104
+ };
105
+ }
106
+
107
+ export function isRateLimitEvent(sdkMessage) {
108
+ return String(sdkMessage?.type || '') === 'rate_limit_event';
109
+ }
110
+
111
+ /** What makes two reports the same to a reader: a repeat is not published. */
112
+ export function rateLimitInfoKey(info) {
113
+ if (!info) return '';
114
+ const percent = info.utilization === null ? '' : Math.round(info.utilization * 100);
115
+ return [info.status, info.rateLimitType || '', info.resetsAt || '', percent, info.isUsingOverage ? 'overage' : ''].join('|');
116
+ }
117
+
118
+ /**
119
+ * Did the CLI refuse this turn because the usage limit is reached?
120
+ *
121
+ * The ONE place that decides it. Returns null, or what the relay needs to
122
+ * pause the turn: `{ rateLimitType, resetsAt, text }`. `resetsAt` is null
123
+ * when the report names no reset still ahead; the relay then tries again
124
+ * shortly.
125
+ *
126
+ * - `rateLimit`: the latest normalized `rate_limit_info` of the process.
127
+ * - `result`: the raw SDK `result` message the turn ended with, if any.
128
+ * - `assistantError`: the `error` field of the turn's last assistant message.
129
+ * - `errorText`: the text of an exception, when the stream threw instead.
130
+ *
131
+ * A refusal needs both halves: the turn ended in an error that names the
132
+ * limit, and the CLI reported a rejected window, with its reset still ahead
133
+ * or just now. Without a rejected report (the limit reached in the middle of
134
+ * a long turn, under the report the turn began with) the error has to name
135
+ * the limit twice over: by its kind and in its words. A plain API 429 (an
136
+ * API key's requests per minute) comes with no report at all and stays the
137
+ * failure it is; a rejected window the account's extra usage covers ends no
138
+ * turn.
139
+ */
140
+ export function classifyUsageLimitRefusal({
141
+ rateLimit = null,
142
+ result = null,
143
+ assistantError = '',
144
+ errorText = '',
145
+ now = Date.now(),
146
+ } = {}) {
147
+ if (!rateLimit) return null;
148
+ const resultText = String(result?.result || '').trim();
149
+ const thrownText = String(errorText || '').trim();
150
+ const endedInError = result ? result.is_error === true || String(result.subtype || '') !== 'success' : Boolean(thrownText);
151
+ if (!endedInError) return null;
152
+
153
+ const byKind = String(assistantError || '') === 'rate_limit' || Number(result?.api_error_status) === 429;
154
+ const inWords = LIMIT_TEXT.test(resultText) || LIMIT_TEXT.test(thrownText);
155
+ const rejected = rateLimit.status === 'rejected';
156
+ const resetsAtMs = Date.parse(rateLimit.resetsAt || '');
157
+ const resetAhead = Number.isFinite(resetsAtMs) && resetsAtMs > now;
158
+
159
+ if (rejected) {
160
+ if (!byKind && !inWords) return null;
161
+ if (!resetAhead) {
162
+ const observedAtMs = Date.parse(rateLimit.observedAt || '');
163
+ if (!Number.isFinite(observedAtMs) || now - observedAtMs > USAGE_LIMIT_REPORT_FRESH_MS) return null;
164
+ }
165
+ } else if (!byKind || !inWords) {
166
+ return null;
167
+ }
168
+
169
+ // A warning names the window that was about to run out; a report that
170
+ // allowed everything says nothing about which reset to wait for.
171
+ const namesTheReset = resetAhead && (rejected || rateLimit.status === 'allowed_warning');
172
+ return {
173
+ rateLimitType: rejected || namesTheReset ? rateLimit.rateLimitType : null,
174
+ resetsAt: namesTheReset ? rateLimit.resetsAt : null,
175
+ text: resultText || thrownText,
176
+ };
177
+ }
178
+
179
+ /** When a paused turn is sent again: the reset plus a margin. */
180
+ export function usageLimitResumeAt(resetsAt, { marginMs = USAGE_LIMIT_RESUME_MARGIN_MS } = {}) {
181
+ const ms = Date.parse(resetsAt || '');
182
+ return Number.isFinite(ms) ? new Date(ms + marginMs).toISOString() : null;
183
+ }
184
+
185
+ /** Whether a pause ends by itself, or waits for the user (a reset days away). */
186
+ export function usageLimitResumesByItself(resetsAt, { now = Date.now(), maxWaitMs = USAGE_LIMIT_AUTO_RESUME_MAX_MS } = {}) {
187
+ const ms = Date.parse(resetsAt || '');
188
+ return Number.isFinite(ms) && ms - now <= maxWaitMs;
189
+ }
190
+
191
+ /**
192
+ * What the worker reports for a refused turn, inside the response's
193
+ * `terminalError`. A relay that knows the kind pauses the row; an older one
194
+ * fails the turn with this message, as before.
195
+ */
196
+ export function buildUsageLimitFailure(message, refusal) {
197
+ const label = usageLimitWindowLabel(refusal?.rateLimitType);
198
+ return {
199
+ kind: USAGE_LIMIT_KIND,
200
+ code: USAGE_LIMIT_CODE,
201
+ stableCode: USAGE_LIMIT_STABLE_CODE,
202
+ message: String(refusal?.text || '').trim() || `The Claude ${label} is reached.`,
203
+ guidance: 'The turn carries on by itself after the reset.',
204
+ rateLimitType: refusal?.rateLimitType || null,
205
+ resetsAt: refusal?.resetsAt || null,
206
+ failedAt: new Date().toISOString(),
207
+ queueMessageId: String(message?.id || '') || null,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Per-process bookkeeping for the worker: the latest report, what the turn's
213
+ * last assistant message said, and which reports are worth publishing.
214
+ */
215
+ export function createUsageLimitTracker({ now = () => Date.now() } = {}) {
216
+ let latest = null;
217
+ let publishedKey = '';
218
+ let assistantError = '';
219
+
220
+ /** Returns the normalized report when it is news to the relay, else null. */
221
+ function observe(sdkMessage) {
222
+ const type = String(sdkMessage?.type || '');
223
+ if (type === 'rate_limit_event') {
224
+ const info = normalizeRateLimitInfo(sdkMessage.rate_limit_info, { now: now() });
225
+ if (!info) return null;
226
+ latest = info;
227
+ const key = rateLimitInfoKey(info);
228
+ if (key === publishedKey) return null;
229
+ publishedKey = key;
230
+ return info;
231
+ }
232
+ if (type === 'assistant' && !sdkMessage.parent_tool_use_id) {
233
+ assistantError = String(sdkMessage.error || '').trim();
234
+ }
235
+ return null;
236
+ }
237
+
238
+ function classifyResult(sdkResult) {
239
+ return classifyUsageLimitRefusal({ rateLimit: latest, result: sdkResult, assistantError, now: now() });
240
+ }
241
+
242
+ function classifyException(error) {
243
+ return classifyUsageLimitRefusal({
244
+ rateLimit: latest,
245
+ assistantError,
246
+ errorText: String(error?.message || error || ''),
247
+ now: now(),
248
+ });
249
+ }
250
+
251
+ return {
252
+ observe,
253
+ classifyResult,
254
+ classifyException,
255
+ get latest() { return latest; },
256
+ };
257
+ }