agent-relay-orchestrator 0.78.1 → 0.78.3

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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/quota-poller.ts +111 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.78.1",
3
+ "version": "0.78.3",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-sdk": "0.2.54"
19
+ "agent-relay-sdk": "0.2.55"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { existsSync, readdirSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { createServer } from "node:net";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -9,8 +9,10 @@ import {
9
9
  QuotaCollectionError,
10
10
  collectClaudeQuotaSample,
11
11
  collectCodexQuotaSample,
12
+ credentialAccountKey,
12
13
  providerQuotaErrorFromCollectorError,
13
14
  quotaRetryAfterMs,
15
+ readClaudeOAuthAccessToken,
14
16
  resolveStableClaudeQuotaIdentity,
15
17
  resolveStableCodexQuotaIdentityFromHome,
16
18
  type ProviderQuotaIdentity,
@@ -25,6 +27,14 @@ const QUOTA_LEASE_RENEW_MS = 30_000;
25
27
  const CODEX_APP_SERVER_CONNECT_ATTEMPTS = 40;
26
28
  const CODEX_APP_SERVER_CONNECT_RETRY_MS = 250;
27
29
 
30
+ // A provider that is configured but has no usable quota credential on this host
31
+ // is uncollectable by design. Rather than silently omitting it (so the row
32
+ // vanishes from the dashboard), we report a marker record carrying the reason,
33
+ // so the widget can show a muted "unavailable" row.
34
+ // A stable synthetic account key keeps it out of the host:/home: unstable-key prune.
35
+ const PROVIDER_QUOTA_UNAVAILABLE_ACCOUNT_KEY = "unavailable";
36
+ const PROVIDER_QUOTA_UNAVAILABLE_ERROR_TYPE = "unavailable";
37
+
28
38
  type QuotaRelay = {
29
39
  acquireProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput): Promise<{
30
40
  acquired: boolean;
@@ -37,10 +47,14 @@ type QuotaRelay = {
37
47
  };
38
48
 
39
49
  type QuotaCandidate = ProviderQuotaIdentity & {
50
+ accessToken?: string;
40
51
  appServerUrl?: string;
41
52
  codexHome?: string;
42
53
  };
43
54
 
55
+ type ProviderSkip = { provider: string; reason: string };
56
+ type QuotaDiscovery = { candidates: QuotaCandidate[]; skips: ProviderSkip[] };
57
+
44
58
  type QuotaPollState = {
45
59
  leaseToken?: string;
46
60
  leaseExpiresAt?: number;
@@ -91,11 +105,14 @@ export class OrchestratorQuotaPoller {
91
105
  if (this.inFlight || !this.active || !this.relay.connected) return;
92
106
  this.inFlight = true;
93
107
  try {
94
- const candidates = await this.discoverCandidates();
108
+ const { candidates, skips } = await this.discoverCandidates();
95
109
  await this.releaseRemovedCandidates(candidates);
96
110
  for (const candidate of candidates) {
97
111
  await this.processCandidate(candidate);
98
112
  }
113
+ for (const skip of skips) {
114
+ await this.reportSkip(skip);
115
+ }
99
116
  } finally {
100
117
  this.inFlight = false;
101
118
  this.schedule(this.options.intervalMs ?? QUOTA_LEASE_RENEW_MS);
@@ -112,38 +129,54 @@ export class OrchestratorQuotaPoller {
112
129
  }, Math.max(1_000, delayMs));
113
130
  }
114
131
 
115
- private async discoverCandidates(): Promise<QuotaCandidate[]> {
132
+ private async discoverCandidates(): Promise<QuotaDiscovery> {
116
133
  const candidates: QuotaCandidate[] = [];
134
+ const skips: ProviderSkip[] = [];
117
135
  if (this.config.providers.includes("claude")) {
118
- candidates.push(...await this.discoverClaudeCandidates());
136
+ const found = await this.discoverClaudeCandidates();
137
+ candidates.push(...found.candidates);
138
+ if (found.skipReason) skips.push({ provider: "claude", reason: found.skipReason });
119
139
  }
120
140
  if (this.config.providers.includes("codex")) {
121
- candidates.push(...await this.discoverCodexCandidates());
141
+ const found = await this.discoverCodexCandidates();
142
+ candidates.push(...found.candidates);
143
+ if (found.skipReason) skips.push({ provider: "codex", reason: found.skipReason });
122
144
  }
123
145
  const deduped = new Map<string, QuotaCandidate>();
124
146
  for (const candidate of candidates) {
125
147
  deduped.set(candidateStateKey(candidate), candidate);
126
148
  }
127
- return [...deduped.values()];
149
+ return { candidates: [...deduped.values()], skips };
128
150
  }
129
151
 
130
- private async discoverClaudeCandidates(): Promise<QuotaCandidate[]> {
131
- const paths = [
152
+ private async discoverClaudeCandidates(): Promise<{ candidates: QuotaCandidate[]; skipReason?: string }> {
153
+ const credentialsPaths = [
132
154
  join(this.config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), ".credentials.json"),
133
155
  ...providerHomeCredentialPaths("claude", ".credentials.json"),
134
156
  ];
135
157
  const candidates: QuotaCandidate[] = [];
136
- for (const credentialsPath of paths) {
158
+ for (const accessToken of [
159
+ this.config.env.CLAUDE_CODE_OAUTH_TOKEN,
160
+ process.env.CLAUDE_CODE_OAUTH_TOKEN,
161
+ ...claudeSetupTokenEnvPaths(this.config).map((path) => readEnvFileValue(path, "CLAUDE_CODE_OAUTH_TOKEN")),
162
+ ]) {
163
+ if (accessToken) candidates.push(claudeBearerCandidate(accessToken));
164
+ }
165
+ for (const credentialsPath of credentialsPaths) {
166
+ const accessToken = await readClaudeOAuthAccessToken(credentialsPath);
167
+ if (!accessToken) continue;
137
168
  const identity = await resolveStableClaudeQuotaIdentity({ credentialsPath });
138
- if (identity) candidates.push(identity);
169
+ candidates.push({ ...(identity ?? claudeBearerCandidate(accessToken)), credentialsPath, accessToken });
139
170
  }
140
171
  if (candidates.length === 0) {
141
- this.logOnce("claude:no-stable-credentials", "quota refresh skipped for claude: no materialized Claude OAuth credentials found");
172
+ const reason = "no usable Claude bearer token on this host";
173
+ this.logOnce("claude:no-usable-bearer", `quota refresh skipped for claude: ${reason}`);
174
+ return { candidates, skipReason: reason };
142
175
  }
143
- return candidates;
176
+ return { candidates };
144
177
  }
145
178
 
146
- private async discoverCodexCandidates(): Promise<QuotaCandidate[]> {
179
+ private async discoverCodexCandidates(): Promise<{ candidates: QuotaCandidate[]; skipReason?: string }> {
147
180
  const homes = [
148
181
  this.config.env.CODEX_HOME || process.env.CODEX_HOME || join(homedir(), ".codex"),
149
182
  ...providerHomeConfigDirs("codex", "auth.json"),
@@ -155,9 +188,25 @@ export class OrchestratorQuotaPoller {
155
188
  if (identity) candidates.push({ ...identity, codexHome, ...(appServerUrl ? { appServerUrl } : {}) });
156
189
  }
157
190
  if (candidates.length === 0) {
158
- this.logOnce("codex:no-stable-auth", "quota refresh skipped for codex: no account id found in Codex auth.json");
191
+ const reason = "no Codex account id found in auth.json";
192
+ this.logOnce("codex:no-stable-auth", `quota refresh skipped for codex: ${reason}`);
193
+ return { candidates, skipReason: reason };
159
194
  }
160
- return candidates;
195
+ return { candidates };
196
+ }
197
+
198
+ // Publish a marker record for a provider that is configured but uncollectable on
199
+ // this host, so the dashboard shows a muted "unavailable" row with the reason
200
+ // instead of dropping the provider entirely. Re-sent each tick to keep the row
201
+ // fresh (the day-old prune is keyed on updated_at).
202
+ private async reportSkip(skip: ProviderSkip): Promise<void> {
203
+ await this.relay.reportProviderQuota({
204
+ provider: skip.provider,
205
+ accountKey: PROVIDER_QUOTA_UNAVAILABLE_ACCOUNT_KEY,
206
+ lastAttemptAt: this.now(),
207
+ lastError: { type: PROVIDER_QUOTA_UNAVAILABLE_ERROR_TYPE, message: skip.reason },
208
+ sourceAgentId: this.sourceAgentId(),
209
+ }).catch((publishError) => this.log(`quota skip publish failed for ${skip.provider}: ${errMessage(publishError)}`));
161
210
  }
162
211
 
163
212
  private async releaseRemovedCandidates(candidates: QuotaCandidate[]): Promise<void> {
@@ -232,6 +281,7 @@ export class OrchestratorQuotaPoller {
232
281
  if (candidate.provider === "claude") {
233
282
  return collectClaudeQuotaSample({
234
283
  agentId: this.sourceAgentId(),
284
+ accessToken: candidate.accessToken,
235
285
  credentialsPath: candidate.credentialsPath,
236
286
  fetchImpl: this.options.fetchImpl,
237
287
  });
@@ -294,9 +344,10 @@ async function codexRateLimitsRead(appServerUrl: string, attempts = 1): Promise<
294
344
  try {
295
345
  await connectWithRetry(client, attempts);
296
346
  await client.request("initialize", {
297
- clientInfo: { name: "agent-relay-orchestrator", title: "Agent Relay Orchestrator" },
347
+ clientInfo: { name: "agent-relay-orchestrator", title: "Agent Relay Orchestrator", version: "0.1.0" },
298
348
  capabilities: { experimentalApi: true },
299
- }).catch(() => undefined);
349
+ });
350
+ client.notify("initialized");
300
351
  return await client.request("account/rateLimits/read");
301
352
  } finally {
302
353
  client.close();
@@ -373,6 +424,45 @@ function providerHomeCredentialPaths(provider: "claude", markerFile: string): st
373
424
  return providerHomeConfigDirs(provider, markerFile).map((dir) => join(dir, markerFile));
374
425
  }
375
426
 
427
+ function claudeSetupTokenEnvPaths(config: OrchestratorConfig): string[] {
428
+ const configDir = config.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
429
+ return [
430
+ join(configDir, "setup-token.env"),
431
+ ...providerHomeConfigDirs("claude", "setup-token.env").map((dir) => join(dir, "setup-token.env")),
432
+ ];
433
+ }
434
+
435
+ function claudeBearerCandidate(accessToken: string): QuotaCandidate {
436
+ return {
437
+ provider: "claude",
438
+ accountKey: credentialAccountKey(accessToken),
439
+ accessToken,
440
+ };
441
+ }
442
+
443
+ function readEnvFileValue(path: string, key: string): string | undefined {
444
+ try {
445
+ for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
446
+ const match = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)\\s*$`).exec(line);
447
+ if (!match) continue;
448
+ return cleanEnvValue(match[1] ?? "");
449
+ }
450
+ } catch {
451
+ return undefined;
452
+ }
453
+ return undefined;
454
+ }
455
+
456
+ function cleanEnvValue(value: string): string | undefined {
457
+ let cleaned = value.trim();
458
+ if (!cleaned) return undefined;
459
+ const quote = cleaned[0];
460
+ if ((quote === "'" || quote === "\"") && cleaned.endsWith(quote)) {
461
+ cleaned = cleaned.slice(1, -1);
462
+ }
463
+ return cleaned.trim() || undefined;
464
+ }
465
+
376
466
  function safeReadDir(path: string): string[] {
377
467
  try {
378
468
  return readdirSync(path, { withFileTypes: true })
@@ -414,6 +504,10 @@ class JsonRpcWebSocket {
414
504
  return promise;
415
505
  }
416
506
 
507
+ notify(method: string, params?: unknown): void {
508
+ this.ws.send(JSON.stringify(params === undefined ? { method } : { method, params }));
509
+ }
510
+
417
511
  close(): void {
418
512
  this.ws?.close();
419
513
  }