@phnx-labs/agents-cli 1.22.44 → 1.22.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +24 -0
- package/dist/bootstrap.js +1 -6
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +1 -3
- package/dist/commands/accounts.d.ts +1 -9
- package/dist/commands/accounts.js +12 -90
- package/dist/commands/auth.d.ts +0 -7
- package/dist/commands/auth.js +198 -83
- package/dist/commands/insights.js +82 -154
- package/dist/commands/view.js +1 -1
- package/dist/lib/accounting/usage.d.ts +22 -3
- package/dist/lib/accounting/usage.js +94 -12
- package/dist/lib/agent-spec/agents.js +6 -1
- package/dist/lib/cli-resources.js +17 -15
- package/dist/lib/devices/harness-inventory.js +20 -3
- package/dist/lib/exec.d.ts +20 -0
- package/dist/lib/exec.js +43 -6
- package/dist/lib/identity/client.d.ts +53 -0
- package/dist/lib/identity/client.js +106 -0
- package/dist/lib/identity/index.d.ts +115 -0
- package/dist/lib/identity/index.js +82 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/probe.d.ts +8 -0
- package/dist/lib/probe.js +105 -0
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +5 -2
- package/dist/lib/view-types.d.ts +2 -2
- package/package.json +1 -1
- package/dist/commands/org.d.ts +0 -11
- package/dist/commands/org.js +0 -228
- package/dist/lib/entitlement.d.ts +0 -31
- package/dist/lib/entitlement.js +0 -137
- package/dist/lib/prix-account.d.ts +0 -159
- package/dist/lib/prix-account.js +0 -215
package/dist/commands/view.js
CHANGED
|
@@ -1314,7 +1314,7 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
|
|
|
1314
1314
|
unavailable: snapshot?.unavailable
|
|
1315
1315
|
? {
|
|
1316
1316
|
reason: snapshot.unavailable.reason,
|
|
1317
|
-
resetsAt: snapshot.unavailable.resetsAt
|
|
1317
|
+
resetsAt: snapshot.unavailable.resetsAt?.toISOString(),
|
|
1318
1318
|
}
|
|
1319
1319
|
: undefined,
|
|
1320
1320
|
lastActive: info.lastActive ? info.lastActive.toISOString() : null,
|
|
@@ -91,10 +91,16 @@ export interface UsageSnapshot {
|
|
|
91
91
|
capturedAt: Date | null;
|
|
92
92
|
windows: UsageWindow[];
|
|
93
93
|
plan?: string | null;
|
|
94
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* A refusal observed from a real harness run, independent of API windows.
|
|
96
|
+
* `session_limit` recovers on a clock (`resetsAt`). `out_of_credits` is a
|
|
97
|
+
* tokens/balance exhaustion that does NOT reset on a clock — it has no
|
|
98
|
+
* `resetsAt` and is cleared only by a later successful run on the account
|
|
99
|
+
* (clearClaudeAccountRefusal). Both exclude the account from rotation while set.
|
|
100
|
+
*/
|
|
95
101
|
unavailable?: {
|
|
96
|
-
reason: 'session_limit';
|
|
97
|
-
resetsAt
|
|
102
|
+
reason: 'session_limit' | 'out_of_credits';
|
|
103
|
+
resetsAt?: Date;
|
|
98
104
|
};
|
|
99
105
|
}
|
|
100
106
|
/** Usage data plus any error encountered while fetching. */
|
|
@@ -515,6 +521,19 @@ export declare function readClaudeUsageCache(usageKey: string, cachePath?: strin
|
|
|
515
521
|
export declare function pruneExpiredClaudeUsageCacheEntry(usageKey: string, cachePath?: string, now?: Date): void;
|
|
516
522
|
/** Write a usage snapshot to the on-disk cache. */
|
|
517
523
|
export declare function writeClaudeUsageCache(usageKey: string, snapshot: UsageSnapshot, cachePath?: string): void;
|
|
524
|
+
/**
|
|
525
|
+
* Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
|
|
526
|
+
* spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
|
|
527
|
+
* on a clock, so no reset time is stored — rotation excludes the account until a
|
|
528
|
+
* later successful run clears it via {@link clearClaudeAccountRefusal}.
|
|
529
|
+
*/
|
|
530
|
+
export declare function noteClaudeOutOfCredits(usageKey: string, cachePath?: string): void;
|
|
531
|
+
/**
|
|
532
|
+
* Clear any persisted refusal marker for an account after a run SUCCEEDS on it.
|
|
533
|
+
* This is the recovery path for `out_of_credits` (which has no clock) and also
|
|
534
|
+
* proactively clears a stale `session_limit` the moment the account serves again.
|
|
535
|
+
*/
|
|
536
|
+
export declare function clearClaudeAccountRefusal(usageKey: string, cachePath?: string): void;
|
|
518
537
|
/**
|
|
519
538
|
* Persist a Claude session-limit refusal from a real run until its stated reset.
|
|
520
539
|
* This quota is not part of Anthropic's five-hour/weekly usage response.
|
|
@@ -398,7 +398,10 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
398
398
|
parts.push(chalk.gray(plan.padEnd(planWidth)));
|
|
399
399
|
}
|
|
400
400
|
if (snapshot) {
|
|
401
|
-
if (snapshot.unavailable?.reason === '
|
|
401
|
+
if (snapshot.unavailable?.reason === 'out_of_credits') {
|
|
402
|
+
parts.push(chalk.red('out of credits'));
|
|
403
|
+
}
|
|
404
|
+
else if (snapshot.unavailable?.reason === 'session_limit' && snapshot.unavailable.resetsAt) {
|
|
402
405
|
parts.push(chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`));
|
|
403
406
|
}
|
|
404
407
|
// Compact rows show BLOCKING windows — the same set
|
|
@@ -472,8 +475,14 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
|
|
|
472
475
|
export function deriveUsageStatusFromSnapshot(snapshot) {
|
|
473
476
|
if (!snapshot)
|
|
474
477
|
return null;
|
|
475
|
-
if (snapshot.unavailable
|
|
476
|
-
|
|
478
|
+
if (snapshot.unavailable) {
|
|
479
|
+
// out_of_credits has no clock — it stays blocking until a successful run
|
|
480
|
+
// clears it. session_limit blocks only until its reset time.
|
|
481
|
+
if (snapshot.unavailable.reason === 'out_of_credits')
|
|
482
|
+
return 'rate_limited';
|
|
483
|
+
if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() > Date.now()) {
|
|
484
|
+
return 'rate_limited';
|
|
485
|
+
}
|
|
477
486
|
}
|
|
478
487
|
if (snapshot.windows.length === 0)
|
|
479
488
|
return null;
|
|
@@ -1487,12 +1496,9 @@ export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeU
|
|
|
1487
1496
|
// refresh cannot drop another account's row (lost update).
|
|
1488
1497
|
const cache = readClaudeUsageCacheFile(cachePath);
|
|
1489
1498
|
const prior = cache[usageKey];
|
|
1490
|
-
const priorReset = parseDateValue(prior?.unavailable?.resetsAt);
|
|
1491
1499
|
cache[usageKey] = serializeClaudeUsageSnapshot({
|
|
1492
1500
|
...snapshot,
|
|
1493
|
-
unavailable:
|
|
1494
|
-
? { reason: 'session_limit', resetsAt: priorReset }
|
|
1495
|
-
: snapshot.unavailable,
|
|
1501
|
+
unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
|
|
1496
1502
|
});
|
|
1497
1503
|
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
1498
1504
|
});
|
|
@@ -1530,7 +1536,10 @@ function serializeClaudeUsageSnapshot(snapshot) {
|
|
|
1530
1536
|
capturedAt: snapshot.capturedAt?.toISOString() || null,
|
|
1531
1537
|
plan: snapshot.plan ?? null,
|
|
1532
1538
|
unavailable: snapshot.unavailable
|
|
1533
|
-
? {
|
|
1539
|
+
? {
|
|
1540
|
+
reason: snapshot.unavailable.reason,
|
|
1541
|
+
resetsAt: snapshot.unavailable.resetsAt?.toISOString(),
|
|
1542
|
+
}
|
|
1534
1543
|
: undefined,
|
|
1535
1544
|
windows: snapshot.windows.map((window) => ({
|
|
1536
1545
|
key: window.key,
|
|
@@ -1565,10 +1574,7 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
|
|
|
1565
1574
|
windowMinutes: window.windowMinutes,
|
|
1566
1575
|
}))
|
|
1567
1576
|
.filter((window) => isCachedUsageWindowFresh(window, capturedAt, now));
|
|
1568
|
-
const
|
|
1569
|
-
const unavailable = unavailableReset && unavailableReset.getTime() > now.getTime()
|
|
1570
|
-
? { reason: 'session_limit', resetsAt: unavailableReset }
|
|
1571
|
-
: undefined;
|
|
1577
|
+
const unavailable = deserializeUnavailable(snapshot.unavailable, now);
|
|
1572
1578
|
if (windows.length === 0 && !unavailable) {
|
|
1573
1579
|
return null;
|
|
1574
1580
|
}
|
|
@@ -1581,6 +1587,82 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
|
|
|
1581
1587
|
unavailable,
|
|
1582
1588
|
};
|
|
1583
1589
|
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Carry a prior refusal marker forward across a daemon usage refresh, and drop
|
|
1592
|
+
* an expired one. A live `snapshot.unavailable` (a refusal just observed) wins.
|
|
1593
|
+
* `out_of_credits` survives refreshes with no reset — only a successful run
|
|
1594
|
+
* clears it (clearClaudeAccountRefusal). A `session_limit` survives only while
|
|
1595
|
+
* its reset time is still in the future.
|
|
1596
|
+
*/
|
|
1597
|
+
function carryForwardUnavailable(prior, live) {
|
|
1598
|
+
if (live)
|
|
1599
|
+
return live;
|
|
1600
|
+
if (!prior)
|
|
1601
|
+
return undefined;
|
|
1602
|
+
if (prior.reason === 'out_of_credits')
|
|
1603
|
+
return { reason: 'out_of_credits' };
|
|
1604
|
+
const reset = parseDateValue(prior.resetsAt);
|
|
1605
|
+
return reset && reset.getTime() > Date.now()
|
|
1606
|
+
? { reason: 'session_limit', resetsAt: reset }
|
|
1607
|
+
: undefined;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Deserialize a cached `unavailable` marker, dropping an expired session_limit
|
|
1611
|
+
* but keeping a clock-less out_of_credits.
|
|
1612
|
+
*/
|
|
1613
|
+
function deserializeUnavailable(cached, now) {
|
|
1614
|
+
if (!cached)
|
|
1615
|
+
return undefined;
|
|
1616
|
+
if (cached.reason === 'out_of_credits')
|
|
1617
|
+
return { reason: 'out_of_credits' };
|
|
1618
|
+
const reset = parseDateValue(cached.resetsAt);
|
|
1619
|
+
return reset && reset.getTime() > now.getTime()
|
|
1620
|
+
? { reason: 'session_limit', resetsAt: reset }
|
|
1621
|
+
: undefined;
|
|
1622
|
+
}
|
|
1623
|
+
/**
|
|
1624
|
+
* Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
|
|
1625
|
+
* spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
|
|
1626
|
+
* on a clock, so no reset time is stored — rotation excludes the account until a
|
|
1627
|
+
* later successful run clears it via {@link clearClaudeAccountRefusal}.
|
|
1628
|
+
*/
|
|
1629
|
+
export function noteClaudeOutOfCredits(usageKey, cachePath = getClaudeUsageCachePath()) {
|
|
1630
|
+
try {
|
|
1631
|
+
ensureLockTarget(cachePath, '{}');
|
|
1632
|
+
withFileLock(cachePath, () => {
|
|
1633
|
+
const cache = readClaudeUsageCacheFile(cachePath);
|
|
1634
|
+
const existing = cache[usageKey] ?? { capturedAt: null, windows: [] };
|
|
1635
|
+
cache[usageKey] = { ...existing, unavailable: { reason: 'out_of_credits' } };
|
|
1636
|
+
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
catch {
|
|
1640
|
+
/* best-effort cache write — lock busy or disk full */
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Clear any persisted refusal marker for an account after a run SUCCEEDS on it.
|
|
1645
|
+
* This is the recovery path for `out_of_credits` (which has no clock) and also
|
|
1646
|
+
* proactively clears a stale `session_limit` the moment the account serves again.
|
|
1647
|
+
*/
|
|
1648
|
+
export function clearClaudeAccountRefusal(usageKey, cachePath = getClaudeUsageCachePath()) {
|
|
1649
|
+
try {
|
|
1650
|
+
if (!fs.existsSync(cachePath))
|
|
1651
|
+
return;
|
|
1652
|
+
withFileLock(cachePath, () => {
|
|
1653
|
+
const cache = readClaudeUsageCacheFile(cachePath);
|
|
1654
|
+
const existing = cache[usageKey];
|
|
1655
|
+
if (!existing?.unavailable)
|
|
1656
|
+
return;
|
|
1657
|
+
const { unavailable: _drop, ...rest } = existing;
|
|
1658
|
+
cache[usageKey] = rest;
|
|
1659
|
+
atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
catch {
|
|
1663
|
+
/* best-effort cache write */
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1584
1666
|
/**
|
|
1585
1667
|
* Persist a Claude session-limit refusal from a real run until its stated reset.
|
|
1586
1668
|
* This quota is not part of Anthropic's five-hour/weekly usage response.
|
|
@@ -21,6 +21,7 @@ import chalk from 'chalk';
|
|
|
21
21
|
import { execFileShellSpec } from '../platform/index.js';
|
|
22
22
|
import { latestFileMtimeMs } from '../fs-walk.js';
|
|
23
23
|
import { damerauLevenshtein } from '../fuzzy.js';
|
|
24
|
+
import { probeCapture } from '../probe.js';
|
|
24
25
|
import { getCacheDir, getVersionsDir, getShimsDir, getHistoryDir, getCliVersionCachePath } from '../state.js';
|
|
25
26
|
import { resolveVersion, getVersionHomePath, getBinaryPath } from '../installations/versions.js';
|
|
26
27
|
import { supports } from '../capabilities.js';
|
|
@@ -1194,7 +1195,11 @@ async function getCachedVersionForBinary(agentId, binaryPath) {
|
|
|
1194
1195
|
const agent = AGENTS[agentId];
|
|
1195
1196
|
let version = null;
|
|
1196
1197
|
try {
|
|
1197
|
-
|
|
1198
|
+
// probeCapture, not bare execFileAsync: a probed harness can fork its own
|
|
1199
|
+
// children (copilot's platform-binary downloader), and a timeout kill of
|
|
1200
|
+
// the direct child would orphan them mid-write (RUSH-3028). The probe runs
|
|
1201
|
+
// in its own process group and the whole group is reaped on settle.
|
|
1202
|
+
const { stdout } = await probeCapture(agent.cliCommand, ['--version'], 3000);
|
|
1198
1203
|
const versionRe = agent.versionStdoutMatch === 'openclaw'
|
|
1199
1204
|
? /openclaw\/(\d+\.\d+\.\d+)/
|
|
1200
1205
|
: /(\d+\.\d+\.\d+)/;
|
|
@@ -21,6 +21,7 @@ import * as path from 'path';
|
|
|
21
21
|
import { spawnSync, execFile } from 'child_process';
|
|
22
22
|
import * as yaml from 'yaml';
|
|
23
23
|
import { listResources, resolveResource } from './resources.js';
|
|
24
|
+
import { probeCapture } from './probe.js';
|
|
24
25
|
import { composeWin32CommandLine } from './platform/index.js';
|
|
25
26
|
import { localBinDir } from './platform/posixpath.js';
|
|
26
27
|
// ─── Validation primitives ───────────────────────────────────────────────────
|
|
@@ -318,22 +319,23 @@ export function isCliInstalledAsync(manifest) {
|
|
|
318
319
|
cmdExistsCache.delete(c.cmd);
|
|
319
320
|
return Promise.resolve(hasCommand(c.cmd));
|
|
320
321
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
322
|
+
// probeCapture, not bare execFile: a checked CLI can fork children of its
|
|
323
|
+
// own (copilot's platform-binary downloader), and settling without reaping
|
|
324
|
+
// the probe's process group would orphan them mid-write (RUSH-3028).
|
|
325
|
+
return probeCapture(c.cmd, c.args, 10_000).then(() => true, (err) => {
|
|
326
|
+
// A spawn failure (as opposed to a non-zero exit) surfaces as a string
|
|
327
|
+
// errno code (ENOENT/EINVAL) on the rejection; a non-zero exit or
|
|
328
|
+
// timeout carries no errno. On Windows a `.cmd`/`.bat` shim spawn-fails
|
|
329
|
+
// without a shell — retry once through the shell, exactly as the sync
|
|
330
|
+
// path does.
|
|
331
|
+
const spawnFailed = typeof err.code === 'string';
|
|
332
|
+
if (process.platform === 'win32' && spawnFailed) {
|
|
333
|
+
const line = composeWin32CommandLine(c.cmd, c.args);
|
|
334
|
+
return new Promise((resolve) => {
|
|
332
335
|
execFile(line, { timeout: 10_000, shell: true }, (retryErr) => resolve(!retryErr));
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return false;
|
|
337
339
|
});
|
|
338
340
|
}
|
|
339
341
|
// ─── Method selection ────────────────────────────────────────────────────────
|
|
@@ -27,15 +27,32 @@ import { listNativeAccounts } from '../account-registry.js';
|
|
|
27
27
|
*/
|
|
28
28
|
export function summarizeQuota(snapshot, unavailableReason = null, accountStatus = null) {
|
|
29
29
|
if (!snapshot || snapshot.windows.length === 0) {
|
|
30
|
-
|
|
30
|
+
// An active refusal marker (persisted out_of_credits, or an unexpired
|
|
31
|
+
// session_limit) blocks even when there are no live utilization windows —
|
|
32
|
+
// which is the normal state hours/days after a run, once cached windows
|
|
33
|
+
// expire. Check it BEFORE trusting the coarse account status, which is
|
|
34
|
+
// hardcoded 'available' for a signed-in Claude; otherwise a tokens-exhausted
|
|
35
|
+
// account reads ready:true here (RUSH-3018 finding, `agents devices harnesses`).
|
|
36
|
+
const marker = snapshot?.unavailable;
|
|
37
|
+
let status = accountStatus;
|
|
38
|
+
let reason = unavailableReason;
|
|
39
|
+
if (marker?.reason === 'out_of_credits') {
|
|
40
|
+
status = 'out_of_credits';
|
|
41
|
+
reason = 'out of credits';
|
|
42
|
+
}
|
|
43
|
+
else if (marker?.reason === 'session_limit' &&
|
|
44
|
+
(!marker.resetsAt || marker.resetsAt.getTime() > Date.now())) {
|
|
45
|
+
status = 'rate_limited';
|
|
46
|
+
reason = 'session-limited';
|
|
47
|
+
}
|
|
31
48
|
return {
|
|
32
49
|
status,
|
|
33
50
|
verdict: status ?? 'unavailable',
|
|
34
51
|
usedPercent: null,
|
|
35
52
|
stale: false,
|
|
36
53
|
capturedAt: snapshot?.capturedAt?.toISOString() ?? null,
|
|
37
|
-
resetsAt: null,
|
|
38
|
-
unavailableReason: status ? null : (
|
|
54
|
+
resetsAt: marker?.resetsAt?.toISOString() ?? null,
|
|
55
|
+
unavailableReason: status ? null : (reason ?? 'usage unavailable'),
|
|
39
56
|
};
|
|
40
57
|
}
|
|
41
58
|
const blocking = snapshot.windows.filter((w) => w.key !== 'sonnet_week');
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -482,6 +482,26 @@ export declare const UNKNOWN_OUTCOME_EXIT_CODE = 1;
|
|
|
482
482
|
export declare const RATE_LIMIT_PATTERNS: RegExp[];
|
|
483
483
|
/** Return true if the text contains any known rate-limit or overload indicator. */
|
|
484
484
|
export declare function detectRateLimit(text: string): boolean;
|
|
485
|
+
export declare function detectOutOfCredits(text: string): boolean;
|
|
486
|
+
/**
|
|
487
|
+
* Classify what a Claude run's output + exit code means for the account's
|
|
488
|
+
* persisted refusal marker. Pure and exported so the persist/clear decision is
|
|
489
|
+
* unit-tested on the real path (runWithFallback can't be driven with a real
|
|
490
|
+
* `claude` spawn in tests). Precedence: a session-limit reset wins (it carries a
|
|
491
|
+
* clock), then a clock-less billing exhaustion, then a clean success clears any
|
|
492
|
+
* stale marker; anything else leaves the marker untouched.
|
|
493
|
+
*/
|
|
494
|
+
export type ClaudeRefusalAction = {
|
|
495
|
+
action: 'note_session';
|
|
496
|
+
resetsAt: Date;
|
|
497
|
+
} | {
|
|
498
|
+
action: 'note_out_of_credits';
|
|
499
|
+
} | {
|
|
500
|
+
action: 'clear';
|
|
501
|
+
} | {
|
|
502
|
+
action: 'none';
|
|
503
|
+
};
|
|
504
|
+
export declare function classifyClaudeRunRefusal(output: string, exitCode: number): ClaudeRefusalAction;
|
|
485
505
|
/**
|
|
486
506
|
* Patterns that indicate an authentication failure — the agent is logged out,
|
|
487
507
|
* its token was revoked, or the session expired. These are the user-visible
|
package/dist/lib/exec.js
CHANGED
|
@@ -38,7 +38,7 @@ import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
|
|
|
38
38
|
import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js';
|
|
39
39
|
import { resolveConfigVersion } from './harness/exec-config-version.js';
|
|
40
40
|
import { getAccountInfo } from './agents.js';
|
|
41
|
-
import { getUsageLookupKey, noteClaudeSessionLimit, parseClaudeSessionLimitReset } from './accounting/usage.js';
|
|
41
|
+
import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
|
|
42
42
|
/**
|
|
43
43
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
44
44
|
*
|
|
@@ -1865,6 +1865,29 @@ export const RATE_LIMIT_PATTERNS = [
|
|
|
1865
1865
|
export function detectRateLimit(text) {
|
|
1866
1866
|
return RATE_LIMIT_PATTERNS.some(pattern => pattern.test(text));
|
|
1867
1867
|
}
|
|
1868
|
+
/**
|
|
1869
|
+
* Narrow detector for a BILLING exhaustion — tokens/credits run out or the
|
|
1870
|
+
* monthly spend cap is hit — as opposed to a time-window rate limit. This class
|
|
1871
|
+
* does NOT recover on a clock, so rotation must remember it per-account
|
|
1872
|
+
* (noteClaudeOutOfCredits) until a later successful run clears it.
|
|
1873
|
+
*/
|
|
1874
|
+
const OUT_OF_CREDITS_PATTERNS = [
|
|
1875
|
+
/out of (?:usage )?credits/i,
|
|
1876
|
+
/spend[\s-]?limit/i,
|
|
1877
|
+
];
|
|
1878
|
+
export function detectOutOfCredits(text) {
|
|
1879
|
+
return OUT_OF_CREDITS_PATTERNS.some(pattern => pattern.test(text));
|
|
1880
|
+
}
|
|
1881
|
+
export function classifyClaudeRunRefusal(output, exitCode) {
|
|
1882
|
+
const sessionLimitReset = parseClaudeSessionLimitReset(output);
|
|
1883
|
+
if (sessionLimitReset)
|
|
1884
|
+
return { action: 'note_session', resetsAt: sessionLimitReset };
|
|
1885
|
+
if (detectOutOfCredits(output))
|
|
1886
|
+
return { action: 'note_out_of_credits' };
|
|
1887
|
+
if (exitCode === 0)
|
|
1888
|
+
return { action: 'clear' };
|
|
1889
|
+
return { action: 'none' };
|
|
1890
|
+
}
|
|
1868
1891
|
/**
|
|
1869
1892
|
* Patterns that indicate an authentication failure — the agent is logged out,
|
|
1870
1893
|
* its token was revoked, or the session expired. These are the user-visible
|
|
@@ -2086,12 +2109,26 @@ export async function runWithFallback(options) {
|
|
|
2086
2109
|
throw err;
|
|
2087
2110
|
}
|
|
2088
2111
|
const output = `${result.stderr}\n${result.stdout}`;
|
|
2112
|
+
// Persist a per-account refusal marker so rotation stops re-picking a
|
|
2113
|
+
// known-dead account: a session-limit recovers on its clock, a billing
|
|
2114
|
+
// exhaustion (tokens/credits) recovers only on a later successful run, and a
|
|
2115
|
+
// clean run clears any stale marker. Decision extracted + unit-tested in
|
|
2116
|
+
// classifyClaudeRunRefusal.
|
|
2089
2117
|
const sessionLimitReset = agent === 'claude' ? parseClaudeSessionLimitReset(output) : null;
|
|
2090
|
-
if (
|
|
2091
|
-
const
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2118
|
+
if (agent === 'claude' && version) {
|
|
2119
|
+
const refusal = classifyClaudeRunRefusal(output, result.exitCode ?? 1);
|
|
2120
|
+
if (refusal.action !== 'none') {
|
|
2121
|
+
const account = await getAccountInfo(agent, getVersionHomePath(agent, version));
|
|
2122
|
+
const usageKey = getUsageLookupKey(account);
|
|
2123
|
+
if (usageKey) {
|
|
2124
|
+
if (refusal.action === 'note_session')
|
|
2125
|
+
noteClaudeSessionLimit(usageKey, refusal.resetsAt);
|
|
2126
|
+
else if (refusal.action === 'note_out_of_credits')
|
|
2127
|
+
noteClaudeOutOfCredits(usageKey);
|
|
2128
|
+
else if (refusal.action === 'clear')
|
|
2129
|
+
clearClaudeAccountRefusal(usageKey);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2095
2132
|
}
|
|
2096
2133
|
if (result.exitCode === 0 && !sessionLimitReset)
|
|
2097
2134
|
return 0;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place agents-cli talks to its account backend (Phoenix ID).
|
|
3
|
+
*
|
|
4
|
+
* Why a seam at all: the removed Prix-coupled layer (RUSH-2581) had no single
|
|
5
|
+
* entry point — the backend URL was hardcoded in five files and the session
|
|
6
|
+
* token was re-read from `~/.rush/user.yaml` by seven separate functions, so
|
|
7
|
+
* re-pointing identity meant editing a dozen call sites and rewriting error
|
|
8
|
+
* strings scattered through the tree. This module is the correction: one base
|
|
9
|
+
* URL, one token reader, one HTTP funnel, one error type. Commands import from
|
|
10
|
+
* here and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* The shape mirrors the seams this repo already proved elsewhere —
|
|
13
|
+
* `SyncBackend` (`lib/secrets/sync-backend.ts`) and `CloudProvider`
|
|
14
|
+
* (`lib/cloud/types.ts`) — so a second identity backend, if one is ever
|
|
15
|
+
* needed, is a swap here rather than a sweep across commands.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Where the account backend lives. Config, never a literal at a call site.
|
|
19
|
+
*
|
|
20
|
+
* The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
|
|
21
|
+
* rather than a vanity hostname because no custom domain is attached yet — and
|
|
22
|
+
* a default naming an unregistered domain is worse than an ugly one: every
|
|
23
|
+
* `agents auth login` would fail DNS with nothing to point at.
|
|
24
|
+
*/
|
|
25
|
+
export declare const PHOENIX_ID_BASE: string;
|
|
26
|
+
/** Our own session file. agents-cli never reads another product's credentials. */
|
|
27
|
+
export declare function sessionFilePath(): string;
|
|
28
|
+
export interface PhoenixSession {
|
|
29
|
+
access_token: string;
|
|
30
|
+
email?: string;
|
|
31
|
+
userId?: string;
|
|
32
|
+
/** Unix ms; absent means the server did not scope the token's lifetime. */
|
|
33
|
+
expires_at?: number;
|
|
34
|
+
}
|
|
35
|
+
export declare function readSession(): PhoenixSession | null;
|
|
36
|
+
export declare function writeSession(session: PhoenixSession): void;
|
|
37
|
+
export declare function clearSession(): void;
|
|
38
|
+
/** An error carrying the server's status and message, so callers can branch on it. */
|
|
39
|
+
export declare class PhoenixApiError extends Error {
|
|
40
|
+
readonly status: number;
|
|
41
|
+
constructor(message: string, status: number);
|
|
42
|
+
}
|
|
43
|
+
interface RequestOptions {
|
|
44
|
+
body?: unknown;
|
|
45
|
+
/** Send the stored session token. Default true; the device-flow start does not. */
|
|
46
|
+
auth?: boolean;
|
|
47
|
+
/** Use this token instead of the stored one (mid-login, before the write). */
|
|
48
|
+
token?: string;
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}
|
|
51
|
+
/** The single HTTP funnel. Every request to the account backend goes through here. */
|
|
52
|
+
export declare function phoenixRequest<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', route: string, opts?: RequestOptions): Promise<T>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place agents-cli talks to its account backend (Phoenix ID).
|
|
3
|
+
*
|
|
4
|
+
* Why a seam at all: the removed Prix-coupled layer (RUSH-2581) had no single
|
|
5
|
+
* entry point — the backend URL was hardcoded in five files and the session
|
|
6
|
+
* token was re-read from `~/.rush/user.yaml` by seven separate functions, so
|
|
7
|
+
* re-pointing identity meant editing a dozen call sites and rewriting error
|
|
8
|
+
* strings scattered through the tree. This module is the correction: one base
|
|
9
|
+
* URL, one token reader, one HTTP funnel, one error type. Commands import from
|
|
10
|
+
* here and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* The shape mirrors the seams this repo already proved elsewhere —
|
|
13
|
+
* `SyncBackend` (`lib/secrets/sync-backend.ts`) and `CloudProvider`
|
|
14
|
+
* (`lib/cloud/types.ts`) — so a second identity backend, if one is ever
|
|
15
|
+
* needed, is a swap here rather than a sweep across commands.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as path from 'path';
|
|
19
|
+
import { getRuntimeStateDir } from '../state.js';
|
|
20
|
+
/**
|
|
21
|
+
* Where the account backend lives. Config, never a literal at a call site.
|
|
22
|
+
*
|
|
23
|
+
* The default is the deployed Phoenix ID Worker. It is a `workers.dev` URL
|
|
24
|
+
* rather than a vanity hostname because no custom domain is attached yet — and
|
|
25
|
+
* a default naming an unregistered domain is worse than an ugly one: every
|
|
26
|
+
* `agents auth login` would fail DNS with nothing to point at.
|
|
27
|
+
*/
|
|
28
|
+
export const PHOENIX_ID_BASE = process.env.PHOENIX_ID_BASE ?? 'https://phoenix-id.muqsitnawaz.workers.dev';
|
|
29
|
+
/** Our own session file. agents-cli never reads another product's credentials. */
|
|
30
|
+
export function sessionFilePath() {
|
|
31
|
+
return path.join(getRuntimeStateDir(), 'phoenix-session.json');
|
|
32
|
+
}
|
|
33
|
+
export function readSession() {
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(sessionFilePath(), 'utf-8');
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
return parsed.access_token ? parsed : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function writeSession(session) {
|
|
44
|
+
const file = sessionFilePath();
|
|
45
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
46
|
+
fs.writeFileSync(file, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
47
|
+
}
|
|
48
|
+
export function clearSession() {
|
|
49
|
+
try {
|
|
50
|
+
fs.rmSync(sessionFilePath(), { force: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Already gone: logging out twice is not an error.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** An error carrying the server's status and message, so callers can branch on it. */
|
|
57
|
+
export class PhoenixApiError extends Error {
|
|
58
|
+
status;
|
|
59
|
+
constructor(message, status) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.status = status;
|
|
62
|
+
this.name = 'PhoenixApiError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The single HTTP funnel. Every request to the account backend goes through here. */
|
|
66
|
+
export async function phoenixRequest(method, route, opts = {}) {
|
|
67
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
68
|
+
if (opts.auth !== false) {
|
|
69
|
+
const token = opts.token ?? readSession()?.access_token;
|
|
70
|
+
if (!token)
|
|
71
|
+
throw new PhoenixApiError("Not signed in. Run 'agents auth login'.", 401);
|
|
72
|
+
headers.Authorization = `Bearer ${token}`;
|
|
73
|
+
}
|
|
74
|
+
let response;
|
|
75
|
+
try {
|
|
76
|
+
response = await fetch(`${PHOENIX_ID_BASE}${route}`, {
|
|
77
|
+
method,
|
|
78
|
+
headers,
|
|
79
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
80
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 15_000),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
85
|
+
throw new PhoenixApiError(`Could not reach the account service (${detail}).`, 0);
|
|
86
|
+
}
|
|
87
|
+
if (response.status === 204)
|
|
88
|
+
return undefined;
|
|
89
|
+
const text = await response.text();
|
|
90
|
+
let payload = null;
|
|
91
|
+
if (text) {
|
|
92
|
+
try {
|
|
93
|
+
payload = JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
payload = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const message = payload && typeof payload === 'object' && 'error' in payload
|
|
101
|
+
? String(payload.error)
|
|
102
|
+
: `${response.status} ${response.statusText}`;
|
|
103
|
+
throw new PhoenixApiError(message, response.status);
|
|
104
|
+
}
|
|
105
|
+
return payload;
|
|
106
|
+
}
|