@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +24 -0
  3. package/dist/bootstrap.js +1 -6
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +1 -3
  6. package/dist/commands/accounts.d.ts +1 -9
  7. package/dist/commands/accounts.js +12 -90
  8. package/dist/commands/auth.d.ts +0 -7
  9. package/dist/commands/auth.js +198 -83
  10. package/dist/commands/insights.js +82 -154
  11. package/dist/commands/view.js +1 -1
  12. package/dist/lib/accounting/usage.d.ts +22 -3
  13. package/dist/lib/accounting/usage.js +94 -12
  14. package/dist/lib/agent-spec/agents.js +6 -1
  15. package/dist/lib/cli-resources.js +17 -15
  16. package/dist/lib/devices/harness-inventory.js +20 -3
  17. package/dist/lib/exec.d.ts +20 -0
  18. package/dist/lib/exec.js +43 -6
  19. package/dist/lib/identity/client.d.ts +53 -0
  20. package/dist/lib/identity/client.js +106 -0
  21. package/dist/lib/identity/index.d.ts +115 -0
  22. package/dist/lib/identity/index.js +82 -0
  23. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  24. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  25. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  28. package/dist/lib/probe.d.ts +8 -0
  29. package/dist/lib/probe.js +105 -0
  30. package/dist/lib/startup/command-registry.d.ts +3 -1
  31. package/dist/lib/startup/command-registry.js +5 -2
  32. package/dist/lib/view-types.d.ts +2 -2
  33. package/package.json +1 -1
  34. package/dist/commands/org.d.ts +0 -11
  35. package/dist/commands/org.js +0 -228
  36. package/dist/lib/entitlement.d.ts +0 -31
  37. package/dist/lib/entitlement.js +0 -137
  38. package/dist/lib/prix-account.d.ts +0 -159
  39. package/dist/lib/prix-account.js +0 -215
@@ -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.toISOString(),
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
- /** A refusal observed from a real harness run, independent of API windows. */
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: Date;
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 === 'session_limit') {
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 && snapshot.unavailable.resetsAt.getTime() > Date.now()) {
476
- return 'rate_limited';
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: priorReset && priorReset.getTime() > Date.now()
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
- ? { reason: snapshot.unavailable.reason, resetsAt: snapshot.unavailable.resetsAt.toISOString() }
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 unavailableReset = parseDateValue(snapshot.unavailable?.resetsAt);
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
- const { stdout } = await execFileAsync(agent.cliCommand, ['--version'], { timeout: 3000 });
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
- return new Promise((resolve) => {
322
- execFile(c.cmd, c.args, { timeout: 10_000 }, (err) => {
323
- if (!err)
324
- return resolve(true);
325
- // A spawn failure (as opposed to a non-zero exit) surfaces as a string
326
- // errno code (ENOENT/EINVAL); a non-zero exit surfaces as a numeric code.
327
- // On Windows a `.cmd`/`.bat` shim spawn-fails without a shell — retry once
328
- // through the shell, exactly as the sync path does.
329
- const spawnFailed = typeof err.code === 'string';
330
- if (process.platform === 'win32' && spawnFailed) {
331
- const line = composeWin32CommandLine(c.cmd, c.args);
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
- return;
334
- }
335
- resolve(false);
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
- const status = accountStatus;
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 : (unavailableReason ?? 'usage unavailable'),
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');
@@ -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 (sessionLimitReset && version) {
2091
- const account = await getAccountInfo(agent, getVersionHomePath(agent, version));
2092
- const usageKey = getUsageLookupKey(account);
2093
- if (usageKey)
2094
- noteClaudeSessionLimit(usageKey, sessionLimitReset);
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
+ }