@phnx-labs/agents-cli 1.22.45 → 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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.46
4
+
5
+ - **`agents auth` returns, against Phoenix ID instead of a sibling product's backend (RUSH-2581).** 1.22.45 removed the account layer that authenticated against Rush's `api.prix.dev`. It comes back pointed at **Phoenix ID** (`phnx-labs/phoenix-id`), agents-cli's own account service: `agents auth login` runs a device-code flow whose browser page is Phoenix-branded and Google-only, `agents auth whoami` reports the signed-in account, `agents auth logout` clears **this machine** and nothing else, and the team surface nests as `agents auth space` (`list`/`create`/`members`/`invite`/`role`/`remove`). Everything goes through one new seam, `lib/identity/` — one base URL (`PHOENIX_ID_BASE`), one session file, one HTTP funnel, one error type — replacing the shape that had the backend URL hardcoded in five files and the session token re-read by seven separate functions. agents-cli reads no other product's credentials: there is no `~/.rush/user.yaml` fallback. Source: `apps/cli/src/lib/identity/{client,index}.ts`, `apps/cli/src/commands/auth.ts`.
6
+
7
+ - **`agents auth` points at the deployed Phoenix ID service (RUSH-2581).** The `PHOENIX_ID_BASE` default shipped naming `id.phnx.sh`, a domain that was never registered, so every `agents auth login` would have failed DNS with nothing behind it. It now defaults to the live service (a `workers.dev` URL until a custom hostname is attached). Override it with `PHOENIX_ID_BASE` to point at a local backend. Source: `apps/cli/src/lib/identity/client.ts`.
8
+
9
+ - **The menu-bar prepack gate hard-fails on an unstapled or thin helper on any OS (RUSH-3031).** `verify-menubar-helper.sh`'s notarization check used to silently no-op when `xcrun` was absent — the exact case a Linux attestation-producer box hits — which let 1.22.44 ship a Dev-ID-signed but un-stapled, thin (single-arch) `MenubarHelper.app` that Gatekeeper rejected on every Mac. The gate now hard-fails when the stapled ticket (`Contents/CodeResources`) is missing and `xcrun` is unavailable, and hard-fails when the bundled executable is not a universal (fat) Mach-O binary, checked portably with `od` on any platform. Source: `apps/cli/scripts/verify-menubar-helper.sh`.
10
+ - **The release-attestation producer no longer arms the real-`~/.agents` hermeticity guards it needs to get vitest's extended timeout profile (RUSH-3007).** `CI=true` used to control both the vitest hookTimeout/ignore-pool-error profile AND `tests/setup.ts`'s leak tripwires against the real developer home — so a producer run on a box with a live daemon (e.g. mac-mini) false-failed 129/129 test files on a fully green, 12,559/12,559-test suite. `release-attestation-produce.sh` now sets `AGENTS_ATTEST_PRODUCER=1` (and unsets any ambient `CI`) to opt into the timeout profile without arming the guards; a genuine CI runner's behavior is unchanged. Source: `apps/cli/scripts/release-attestation-produce.sh`, `apps/cli/tests/hermetic-guards.ts`, `apps/cli/tests/setup.ts`, `apps/cli/vitest.config.ts`.
11
+
12
+ - **Rotation remembers a tokens/credits-exhausted account instead of re-picking it every launch (RUSH-3018).**
13
+ A rate limit resets on a clock; running *out of credits / hitting a spend cap*
14
+ does not — yet the two were conflated. A billing refusal (`out of usage credits`,
15
+ `monthly spend limit`) was detected only to trigger one failover and then
16
+ forgotten, so balanced rotation re-picked the dead account on the next launch,
17
+ it refused again, failed over again — burning a launch each time. It now
18
+ persists a clock-less `out_of_credits` marker per account
19
+ (`noteClaudeOutOfCredits`), which the rotation eligibility gate treats as
20
+ blocking until a later **successful** run on that account clears it
21
+ (`clearClaudeAccountRefusal`) — never a timestamp. Session/rate limits are
22
+ unchanged (still recover on their reset). Source:
23
+ `apps/cli/src/lib/accounting/usage.ts`, `apps/cli/src/lib/exec.ts`.
24
+
25
+ - **Harness capability probes can no longer leak grandchild processes (RUSH-3028).** The version probe behind `agents view` (`<cli> --version`) and the async manifest `check:` runner executed third-party binaries whose own forked children could outlive the probe — the GitHub Copilot npm wrapper forks a platform-binary downloader into `~/Library/Caches/copilot`, and under a redirected test HOME that survivor raced teardown `rm` (the dominant residual ENOTEMPTY suite flake after RUSH-3021). Probes now run in their own process group via `probeCapture`, and the whole group is reaped when the probe settles — on clean exit, on timeout, and on parent death (a `process.on('exit')` hook covers the CLI's hard-exit SIGINT path). On win32, where process groups don't apply, the direct child is killed on settle, matching the old `execFile` timeout behavior. Source: `apps/cli/src/lib/probe.ts`, `apps/cli/src/lib/agent-spec/agents.ts`, `apps/cli/src/lib/cli-resources.ts`.
26
+
3
27
  ## 1.22.45
4
28
 
5
29
  - **BREAKING: the Prix-coupled account layer is removed — `agents auth`, `agents org`, and the plan-tier gates (RUSH-2581).** 1.22.42 shipped `agents auth login/whoami/logout` and `agents org` against the Rush product's backend (`api.prix.dev`), with `entitlement.ts` reading the Rush billing tier to cap accounts at 3-per-harness on free and to gate the `agents insights` friction sections. agents-cli's identity must not ride a separate product's login and database, so all of it is removed: `auth` and `org` are retired top-level names (they fail loudly, no auto-correct), account registration is uncapped again, `agents accounts` output drops the `dormant` field/suffix, and `agents insights` renders the full report with no `plan`/`notice` JSON fields. The Rush *feature* integrations are untouched (`cloud/rush.ts` dispatch, the opt-in secrets sync driver, the owner-notify transport). The replacement — agents-cli's own account backend with Phoenix branding, built for later Phoenix-ID consolidation — is tracked in RUSH-2581. Source: `apps/cli/src/lib/{prix-account,entitlement}.ts` (deleted), `apps/cli/src/commands/{auth,org}.ts` (deleted), `apps/cli/src/commands/{accounts,insights}.ts`, `apps/cli/src/{bootstrap.ts,cli/command-registry.ts,lib/startup/command-registry.ts}`.
package/README.md CHANGED
@@ -81,6 +81,7 @@ Also available as `ag` -- all commands work with both `agents` and `ag`.
81
81
  - [Plugins](#plugins)
82
82
  - [Make it yours](#make-it-yours)
83
83
  - [Browser](#browser)
84
+ - [Sign in](#sign-in)
84
85
  - [Accounts](#accounts)
85
86
  - [Secrets](#secrets)
86
87
  - [Routines](#routines)
@@ -1002,6 +1003,29 @@ agents browser profiles create cloud \
1002
1003
 
1003
1004
  ---
1004
1005
 
1006
+ ## Sign in
1007
+
1008
+ `agents auth` signs this machine in to **Phoenix ID** — the Phoenix Labs account layer that spaces and plan tiers hang off. Sign-in is Google-only and runs a device-code flow: the CLI shows a code, your browser confirms it, and the CLI picks the session up.
1009
+
1010
+ ```bash
1011
+ agents auth login # shows a code, opens a Phoenix-branded page
1012
+ agents auth whoami # who this machine is signed in as (--json)
1013
+ agents auth logout # clears THIS machine; no other device is touched
1014
+
1015
+ agents auth space create "Design Team" # spaces are the team primitive
1016
+ agents auth space list # spaces you belong to
1017
+ agents auth space invite ada@example.com --role member
1018
+ agents auth space members # who is in it
1019
+ agents auth space role ada@example.com admin
1020
+ agents auth space remove ada@example.com # or remove yourself to leave
1021
+ ```
1022
+
1023
+ The session lives in this machine's agents state dir, so `logout` here signs out nothing else. `whoami` and every `space` subcommand take `--json`. `PHOENIX_ID_BASE` points the CLI at a different account service (a local one, for instance); it defaults to the deployed Phoenix ID service.
1024
+
1025
+ Distinct from **Accounts** below: this is *your human identity*; those are the *harness credentials* an agent runs under.
1026
+
1027
+ ---
1028
+
1005
1029
  ## Accounts
1006
1030
 
1007
1031
  Give a provider credential a durable name once, reuse it everywhere -- across harnesses, across machines.
@@ -90,6 +90,7 @@ export declare const loadWebhooks: ModuleLoader;
90
90
  export declare const loadHumans: ModuleLoader;
91
91
  export declare const loadAccounts: ModuleLoader;
92
92
  export declare const loadDaemon: ModuleLoader;
93
+ export declare const loadAuth: ModuleLoader;
93
94
  /**
94
95
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
95
96
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -93,6 +93,7 @@ export const loadWebhooks = async () => (await import('../commands/webhook.js'))
93
93
  export const loadHumans = async () => (await import('../commands/humans.js')).registerHumansCommands;
94
94
  export const loadAccounts = async () => (await import('../commands/accounts.js')).registerAccountsCommand;
95
95
  export const loadDaemon = async () => (await import('../commands/daemon.js')).registerDaemonCommand;
96
+ export const loadAuth = async () => (await import('../commands/auth.js')).registerAuthCommand;
96
97
  /**
97
98
  * Commands whose modules pull in the SQLite-backed session/cloud stack. They are
98
99
  * registered AFTER `applyGlobalHelpConventions` (mirroring main's order: help
@@ -207,6 +208,7 @@ export const COMMAND_LOADERS = {
207
208
  webhooks: [loadWebhooks],
208
209
  humans: [loadHumans],
209
210
  daemon: [loadDaemon],
211
+ auth: [loadAuth],
210
212
  };
211
213
  /**
212
214
  * Register every module in {@link COMMAND_LOADERS} onto one fresh program and
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerAuthCommand(program: Command): void;
@@ -0,0 +1,227 @@
1
+ import chalk from 'chalk';
2
+ import { PhoenixApiError, clearSession, createSpace, createSpaceInvite, fetchWhoAmI, listSpaceMembers, listSpaces, pollDeviceToken, readSession, removeSpaceMember, resolveMemberFromList, resolveSpaceFromList, slugify, startDeviceAuthorization, updateSpaceMemberRole, writeSession, } from '../lib/identity/index.js';
3
+ import { setHelpSections } from '../lib/help.js';
4
+ import { runOrDie } from '../lib/format.js';
5
+ /**
6
+ * `agents auth` — sign in to Phoenix ID, the account layer behind teams and
7
+ * plan tiers. Everything here goes through `lib/identity`; this file builds no
8
+ * URLs and reads no credential files of its own.
9
+ */
10
+ function sleep(ms) {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+ async function login() {
14
+ const grant = await startDeviceAuthorization();
15
+ console.log('');
16
+ console.log(` Your code: ${chalk.bold.cyan(grant.user_code)}`);
17
+ console.log(` Open: ${chalk.underline(grant.verification_uri_complete)}`);
18
+ console.log('');
19
+ console.log(chalk.gray(' Waiting for you to approve it in the browser…'));
20
+ // The server sets the pace; `slow_down` widens it (RFC 8628 §3.5).
21
+ let interval = Math.max(1, grant.interval) * 1000;
22
+ const deadline = Date.now() + grant.expires_in * 1000;
23
+ while (Date.now() < deadline) {
24
+ await sleep(interval);
25
+ const poll = await pollDeviceToken(grant.device_code);
26
+ if (poll.status === 'authorized') {
27
+ writeSession({ access_token: poll.access_token, email: poll.user.email, userId: poll.user.id });
28
+ console.log(chalk.green(`\n Signed in as ${poll.user.email}.`));
29
+ return;
30
+ }
31
+ if (poll.status === 'slow_down') {
32
+ interval += 5000;
33
+ continue;
34
+ }
35
+ if (poll.status === 'denied')
36
+ throw new Error('Sign-in was denied in the browser.');
37
+ if (poll.status === 'expired')
38
+ throw new Error("That code expired. Run 'agents auth login' again.");
39
+ }
40
+ throw new Error("Timed out waiting for approval. Run 'agents auth login' again.");
41
+ }
42
+ async function whoami(json) {
43
+ const session = readSession();
44
+ if (!session) {
45
+ if (json) {
46
+ console.log(JSON.stringify({ signedIn: false }, null, 2));
47
+ return;
48
+ }
49
+ console.log(chalk.gray("Not signed in. Run 'agents auth login'."));
50
+ process.exitCode = 1;
51
+ return;
52
+ }
53
+ try {
54
+ const me = await fetchWhoAmI();
55
+ if (json) {
56
+ console.log(JSON.stringify({ signedIn: true, ...me }, null, 2));
57
+ return;
58
+ }
59
+ console.log(`${chalk.bold(me.email)} ${chalk.gray(me.userId)}`);
60
+ }
61
+ catch (err) {
62
+ if (err instanceof PhoenixApiError && err.status === 401) {
63
+ throw new Error("Your session is no longer valid. Run 'agents auth login' again.");
64
+ }
65
+ throw err;
66
+ }
67
+ }
68
+ function printSpaces(spaces) {
69
+ if (!spaces.length) {
70
+ console.log(chalk.gray(" No spaces yet. Create one with 'agents auth space create <name>'."));
71
+ return;
72
+ }
73
+ for (const space of spaces) {
74
+ console.log(` ${chalk.cyan(space.slug)} ${space.name} ${chalk.gray(space.user_role)}`);
75
+ }
76
+ }
77
+ /** Resolve a space reference (or the caller's only space) to a concrete space. */
78
+ async function requireSpace(ref) {
79
+ const spaces = await listSpaces();
80
+ const space = resolveSpaceFromList(spaces, ref);
81
+ if (space)
82
+ return space;
83
+ if (!ref) {
84
+ throw new Error(spaces.length
85
+ ? `You are in ${spaces.length} spaces — name one: ${spaces.map((s) => s.slug).join(', ')}.`
86
+ : "You are not in a space yet. Create one with 'agents auth space create <name>'.");
87
+ }
88
+ throw new Error(`No space named '${ref}'.`);
89
+ }
90
+ export function registerAuthCommand(program) {
91
+ const auth = program
92
+ .command('auth')
93
+ .description('Sign in to Phoenix ID — the account layer behind spaces and plan tiers');
94
+ setHelpSections(auth, {
95
+ examples: `agents auth login # device-code sign-in via your browser
96
+ agents auth whoami # who this machine is signed in as
97
+ agents auth space create "Design Team" # start a space
98
+ agents auth space invite ada@example.com # add a teammate
99
+ agents auth logout # clear this machine only`,
100
+ notes: `Sign-in is Google-only and opens a Phoenix-branded page; the CLI never sees a password.
101
+ The session lives in this machine's agents state dir, so logging out here signs out nothing else.
102
+ Point at a different backend with PHOENIX_ID_BASE (defaults to the production service).`,
103
+ });
104
+ auth
105
+ .command('login')
106
+ .description('Sign in with the device-code flow')
107
+ .action(() => runOrDie(() => login()));
108
+ auth
109
+ .command('whoami')
110
+ .description('Show the signed-in account')
111
+ .option('--json', 'Machine-readable output')
112
+ .action((o, command) => {
113
+ const json = !!o.json || !!command.optsWithGlobals().json;
114
+ return runOrDie(() => whoami(json), { json });
115
+ });
116
+ auth
117
+ .command('logout')
118
+ .description("Clear this machine's session (no other device is affected)")
119
+ .action(() => runOrDie(() => {
120
+ const session = readSession();
121
+ clearSession();
122
+ console.log(session ? chalk.green(`Signed out ${session.email ?? 'this machine'}.`) : chalk.gray('Already signed out.'));
123
+ }));
124
+ const space = auth.command('space').description('Spaces — share work with teammates');
125
+ space
126
+ .command('list', { isDefault: true })
127
+ .description('Spaces you belong to')
128
+ .option('--json', 'Machine-readable output')
129
+ .action((o, command) => {
130
+ const json = !!o.json || !!command.optsWithGlobals().json;
131
+ return runOrDie(async () => {
132
+ const spaces = await listSpaces();
133
+ if (json)
134
+ return console.log(JSON.stringify(spaces, null, 2));
135
+ printSpaces(spaces);
136
+ }, { json });
137
+ });
138
+ space
139
+ .command('create <name>')
140
+ .description('Create a space')
141
+ .option('--slug <slug>', 'URL-safe name (defaults to a slug of <name>)')
142
+ .option('--json', 'Machine-readable output')
143
+ .action((name, o, command) => {
144
+ const json = !!o.json || !!command.optsWithGlobals().json;
145
+ return runOrDie(async () => {
146
+ const created = await createSpace({ name, slug: o.slug ?? slugify(name) });
147
+ if (json)
148
+ return console.log(JSON.stringify(created, null, 2));
149
+ console.log(chalk.green(`Created ${created.name} (${created.slug}).`));
150
+ }, { json });
151
+ });
152
+ space
153
+ .command('members [space]')
154
+ .description('Who is in a space')
155
+ .option('--json', 'Machine-readable output')
156
+ .action((ref, o, command) => {
157
+ const json = !!o.json || !!command.optsWithGlobals().json;
158
+ return runOrDie(async () => {
159
+ const target = await requireSpace(ref);
160
+ const members = await listSpaceMembers(target.id);
161
+ if (json)
162
+ return console.log(JSON.stringify(members, null, 2));
163
+ for (const m of members)
164
+ console.log(` ${m.email} ${chalk.gray(m.role)}`);
165
+ }, { json });
166
+ });
167
+ space
168
+ .command('invite <email>')
169
+ .description('Invite someone to a space')
170
+ .option('--space <space>', 'Which space (defaults to your only one)')
171
+ .option('--role <role>', 'admin or member', 'member')
172
+ .option('--json', 'Machine-readable output')
173
+ .action((email, o, command) => {
174
+ const json = !!o.json || !!command.optsWithGlobals().json;
175
+ return runOrDie(async () => {
176
+ if (o.role !== 'admin' && o.role !== 'member') {
177
+ throw new Error(`--role must be admin or member (got '${o.role}').`);
178
+ }
179
+ const target = await requireSpace(o.space);
180
+ const result = await createSpaceInvite(target.id, { email, role: o.role });
181
+ if (json)
182
+ return console.log(JSON.stringify(result, null, 2));
183
+ console.log(result.member_added
184
+ ? chalk.green(`Added ${email} to ${target.name} as ${o.role}.`)
185
+ : chalk.green(`Invited ${email} to ${target.name} as ${o.role}. Invite code: ${result.invite_code}`));
186
+ }, { json });
187
+ });
188
+ space
189
+ .command('role <email> <role>')
190
+ .description('Change a member\'s role (owner only for admin)')
191
+ .option('--space <space>', 'Which space (defaults to your only one)')
192
+ .option('--json', 'Machine-readable output')
193
+ .action((email, role, o, command) => {
194
+ const json = !!o.json || !!command.optsWithGlobals().json;
195
+ return runOrDie(async () => {
196
+ if (role !== 'admin' && role !== 'member') {
197
+ throw new Error(`role must be admin or member (got '${role}').`);
198
+ }
199
+ const target = await requireSpace(o.space);
200
+ const member = resolveMemberFromList(await listSpaceMembers(target.id), email);
201
+ if (!member)
202
+ throw new Error(`${email} is not in ${target.name}.`);
203
+ const updated = await updateSpaceMemberRole(target.id, member.user_id, role);
204
+ if (json)
205
+ return console.log(JSON.stringify(updated, null, 2));
206
+ console.log(chalk.green(`${email} is now ${role} in ${target.name}.`));
207
+ }, { json });
208
+ });
209
+ space
210
+ .command('remove <email>')
211
+ .description('Remove a member (or yourself) from a space')
212
+ .option('--space <space>', 'Which space (defaults to your only one)')
213
+ .option('--json', 'Machine-readable output')
214
+ .action((email, o, command) => {
215
+ const json = !!o.json || !!command.optsWithGlobals().json;
216
+ return runOrDie(async () => {
217
+ const target = await requireSpace(o.space);
218
+ const member = resolveMemberFromList(await listSpaceMembers(target.id), email);
219
+ if (!member)
220
+ throw new Error(`${email} is not in ${target.name}.`);
221
+ await removeSpaceMember(target.id, member.user_id);
222
+ if (json)
223
+ return console.log(JSON.stringify({ removed: true, email, space: target.slug }, null, 2));
224
+ console.log(chalk.green(`Removed ${email} from ${target.name}.`));
225
+ }, { json });
226
+ });
227
+ }
@@ -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
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Phoenix ID — the typed surface commands use. Every route the account backend
3
+ * exposes is a function here; no command builds a URL or reads a token itself.
4
+ */
5
+ import { type PhoenixSession } from './client.js';
6
+ export { PHOENIX_ID_BASE, PhoenixApiError, clearSession, readSession, sessionFilePath, writeSession, type PhoenixSession, } from './client.js';
7
+ export interface DeviceAuthorization {
8
+ device_code: string;
9
+ user_code: string;
10
+ verification_uri: string;
11
+ verification_uri_complete: string;
12
+ expires_in: number;
13
+ interval: number;
14
+ }
15
+ export interface WhoAmI {
16
+ userId: string;
17
+ email: string;
18
+ valid: true;
19
+ }
20
+ /**
21
+ * RFC 8628 poll outcomes. `pending` and `slow_down` are normal states of a
22
+ * login in progress, not failures — the server signals them through the error
23
+ * body, and this is where that wire detail stops.
24
+ */
25
+ export type DevicePoll = {
26
+ status: 'authorized';
27
+ access_token: string;
28
+ user: {
29
+ email: string;
30
+ id: string;
31
+ };
32
+ } | {
33
+ status: 'pending';
34
+ } | {
35
+ status: 'slow_down';
36
+ } | {
37
+ status: 'expired';
38
+ } | {
39
+ status: 'denied';
40
+ };
41
+ export declare function startDeviceAuthorization(): Promise<DeviceAuthorization>;
42
+ export declare function pollDeviceToken(deviceCode: string): Promise<DevicePoll>;
43
+ export declare function fetchWhoAmI(token?: string): Promise<WhoAmI>;
44
+ export interface SpaceSummary {
45
+ id: string;
46
+ slug: string;
47
+ name: string;
48
+ organization_id: string | null;
49
+ owner_user_id: string;
50
+ invite_code?: string;
51
+ user_role: 'owner' | 'admin' | 'member';
52
+ created_at: string;
53
+ }
54
+ export interface SpaceMember {
55
+ user_id: string;
56
+ email: string;
57
+ name?: string;
58
+ avatar_url?: string;
59
+ role: 'owner' | 'admin' | 'member';
60
+ joined_at: string;
61
+ }
62
+ export interface SpaceInvite {
63
+ id: string;
64
+ space_id: string;
65
+ email: string;
66
+ role: 'admin' | 'member';
67
+ invite_code: string;
68
+ created_at: string;
69
+ }
70
+ export type CreateInviteResult = {
71
+ invited: true;
72
+ email: string;
73
+ role: string;
74
+ member_added: true;
75
+ } | {
76
+ invited: true;
77
+ email: string;
78
+ role: string;
79
+ invite_code: string;
80
+ member_added: false;
81
+ };
82
+ export declare const listSpaces: () => Promise<SpaceSummary[]>;
83
+ export declare const createSpace: (input: {
84
+ name: string;
85
+ slug: string;
86
+ }) => Promise<SpaceSummary>;
87
+ export declare const getSpace: (id: string) => Promise<SpaceSummary>;
88
+ export declare const listSpaceMembers: (id: string) => Promise<SpaceMember[]>;
89
+ export declare const createSpaceInvite: (id: string, input: {
90
+ email: string;
91
+ role: "admin" | "member";
92
+ }) => Promise<CreateInviteResult>;
93
+ export declare const listSpaceInvites: (id: string) => Promise<SpaceInvite[]>;
94
+ export declare const revokeSpaceInvite: (id: string, inviteId: string) => Promise<{
95
+ revoked: true;
96
+ }>;
97
+ export declare const updateSpaceMemberRole: (id: string, userId: string, role: "admin" | "member") => Promise<{
98
+ user_id: string;
99
+ role: string;
100
+ updated: true;
101
+ }>;
102
+ export declare const removeSpaceMember: (id: string, userId: string) => Promise<void>;
103
+ export declare const deleteSpace: (id: string) => Promise<void>;
104
+ export interface Subscription {
105
+ tierName?: string;
106
+ [key: string]: unknown;
107
+ }
108
+ export declare const fetchSubscription: (agent?: string) => Promise<Subscription>;
109
+ /** `Design Team` → `design-team`; the slug a space gets when the user gives only a name. */
110
+ export declare function slugify(name: string): string;
111
+ /** Resolve a space by id, slug, or name from a list the caller already fetched. */
112
+ export declare function resolveSpaceFromList(spaces: SpaceSummary[], ref?: string): SpaceSummary | null;
113
+ /** Resolve a member by email or user id from a list the caller already fetched. */
114
+ export declare function resolveMemberFromList(members: SpaceMember[], ref: string): SpaceMember | null;
115
+ export type { PhoenixSession as Session };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Phoenix ID — the typed surface commands use. Every route the account backend
3
+ * exposes is a function here; no command builds a URL or reads a token itself.
4
+ */
5
+ import { phoenixRequest, PhoenixApiError } from './client.js';
6
+ export { PHOENIX_ID_BASE, PhoenixApiError, clearSession, readSession, sessionFilePath, writeSession, } from './client.js';
7
+ export function startDeviceAuthorization() {
8
+ return phoenixRequest('POST', '/api/v1/auth/device/authorization', {
9
+ auth: false,
10
+ body: {},
11
+ });
12
+ }
13
+ export async function pollDeviceToken(deviceCode) {
14
+ try {
15
+ return await phoenixRequest('POST', '/api/v1/auth/device/token', {
16
+ auth: false,
17
+ body: {
18
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
19
+ device_code: deviceCode,
20
+ },
21
+ });
22
+ }
23
+ catch (err) {
24
+ if (!(err instanceof PhoenixApiError))
25
+ throw err;
26
+ // The server encodes poll state in the error body (RFC 8628 §3.5).
27
+ if (err.message.includes('authorization_pending'))
28
+ return { status: 'pending' };
29
+ if (err.message.includes('slow_down'))
30
+ return { status: 'slow_down' };
31
+ if (err.message.includes('expired_token'))
32
+ return { status: 'expired' };
33
+ if (err.message.includes('access_denied'))
34
+ return { status: 'denied' };
35
+ throw err;
36
+ }
37
+ }
38
+ export function fetchWhoAmI(token) {
39
+ return phoenixRequest('GET', '/api/v1/auth/me', { token });
40
+ }
41
+ export const listSpaces = () => phoenixRequest('GET', '/api/v1/spaces');
42
+ export const createSpace = (input) => phoenixRequest('POST', '/api/v1/spaces', { body: input });
43
+ export const getSpace = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}`);
44
+ export const listSpaceMembers = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}/members`);
45
+ export const createSpaceInvite = (id, input) => phoenixRequest('POST', `/api/v1/spaces/${encodeURIComponent(id)}/invites`, {
46
+ body: input,
47
+ });
48
+ export const listSpaceInvites = (id) => phoenixRequest('GET', `/api/v1/spaces/${encodeURIComponent(id)}/invites`);
49
+ export const revokeSpaceInvite = (id, inviteId) => phoenixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(id)}/invites/${encodeURIComponent(inviteId)}`);
50
+ export const updateSpaceMemberRole = (id, userId, role) => phoenixRequest('PATCH', `/api/v1/spaces/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`, {
51
+ body: { role },
52
+ });
53
+ export const removeSpaceMember = (id, userId) => phoenixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`);
54
+ export const deleteSpace = (id) => phoenixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(id)}`);
55
+ export const fetchSubscription = (agent = 'agents-cli') => phoenixRequest('GET', `/api/v1/billing/subscription?agent=${encodeURIComponent(agent)}`);
56
+ // ─── Helpers shared by the commands ──────────────────────────────────────────
57
+ /** `Design Team` → `design-team`; the slug a space gets when the user gives only a name. */
58
+ export function slugify(name) {
59
+ return name
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9]+/g, '-')
63
+ .replace(/^-+|-+$/g, '')
64
+ .slice(0, 63);
65
+ }
66
+ /** Resolve a space by id, slug, or name from a list the caller already fetched. */
67
+ export function resolveSpaceFromList(spaces, ref) {
68
+ if (!ref)
69
+ return spaces.length === 1 ? spaces[0] : null;
70
+ const needle = ref.trim().toLowerCase();
71
+ return (spaces.find((s) => s.id === ref) ??
72
+ spaces.find((s) => s.slug.toLowerCase() === needle) ??
73
+ spaces.find((s) => s.name.toLowerCase() === needle) ??
74
+ null);
75
+ }
76
+ /** Resolve a member by email or user id from a list the caller already fetched. */
77
+ export function resolveMemberFromList(members, ref) {
78
+ const needle = ref.trim().toLowerCase();
79
+ return (members.find((m) => m.user_id === ref) ??
80
+ members.find((m) => m.email.toLowerCase() === needle) ??
81
+ null);
82
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Async probe capturing stdout. Rejects on spawn error, non-zero exit, or
3
+ * timeout — matching the `execFileAsync` contract the version probe had — and
4
+ * reaps the probe's whole process group on every settle path.
5
+ */
6
+ export declare function probeCapture(cmd: string, args: string[], timeoutMs: number): Promise<{
7
+ stdout: string;
8
+ }>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Spawn helpers for CAPABILITY PROBES — short-lived invocations of third-party
3
+ * binaries (`copilot --version`, a manifest's `check:` command) whose only job
4
+ * is an exit status or a line of stdout.
5
+ *
6
+ * A probed binary may fork children of its own: the copilot npm wrapper forks
7
+ * a platform-binary downloader into `~/Library/Caches/copilot` on first run
8
+ * under a fresh HOME. Node's `timeout:` option kills only the DIRECT child, so
9
+ * such grandchildren outlive the probe and keep writing — under a test's temp
10
+ * HOME that race is the ENOTEMPTY teardown class (RUSH-3028; residual after
11
+ * RUSH-3021 gated the daemon autostart). Every probe here therefore runs in
12
+ * its OWN process group (`detached`), and the whole group is reaped once the
13
+ * probe settles, so nothing a probe spawned can outlive it.
14
+ *
15
+ * Group semantics are POSIX only: on win32 `detached` means a new console and
16
+ * negative-pid group kills are unsupported, so there the DIRECT child is
17
+ * killed on settle instead — the same guarantee `execFileAsync`'s `timeout:`
18
+ * gave (the grandchild leak class is a darwin/linux temp-HOME teardown race).
19
+ *
20
+ * The parent dying mid-probe is covered too: a detached probe leaves the
21
+ * terminal's foreground group, so a Ctrl-C that hard-exits the CLI
22
+ * (`process.exit(130)` in index.ts) would strand it. Live probe groups are
23
+ * tracked in LIVE_GROUPS and a `process.on('exit')` hook — which runs on
24
+ * every `process.exit` path, including that SIGINT handler — reaps them
25
+ * synchronously.
26
+ */
27
+ import { spawn } from 'child_process';
28
+ const GROUP_REAP = process.platform !== 'win32';
29
+ const LIVE_GROUPS = new Set();
30
+ let exitHookInstalled = false;
31
+ function ensureExitHook() {
32
+ if (exitHookInstalled)
33
+ return;
34
+ exitHookInstalled = true;
35
+ process.on('exit', () => {
36
+ for (const pid of LIVE_GROUPS) {
37
+ try {
38
+ process.kill(-pid, 'SIGKILL');
39
+ }
40
+ catch {
41
+ /* group already fully exited */
42
+ }
43
+ }
44
+ });
45
+ }
46
+ function reapGroup(pid) {
47
+ if (!GROUP_REAP || !pid)
48
+ return;
49
+ LIVE_GROUPS.delete(pid);
50
+ try {
51
+ process.kill(-pid, 'SIGKILL');
52
+ }
53
+ catch {
54
+ /* group already fully exited */
55
+ }
56
+ }
57
+ /**
58
+ * Async probe capturing stdout. Rejects on spawn error, non-zero exit, or
59
+ * timeout — matching the `execFileAsync` contract the version probe had — and
60
+ * reaps the probe's whole process group on every settle path.
61
+ */
62
+ export function probeCapture(cmd, args, timeoutMs) {
63
+ return new Promise((resolve, reject) => {
64
+ const child = spawn(cmd, args, {
65
+ detached: GROUP_REAP,
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ windowsHide: true,
68
+ });
69
+ if (GROUP_REAP && child.pid) {
70
+ LIVE_GROUPS.add(child.pid);
71
+ ensureExitHook();
72
+ }
73
+ let out = '';
74
+ let settled = false;
75
+ const settle = (err) => {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ clearTimeout(timer);
80
+ reapGroup(child.pid);
81
+ // win32 has no group to reap: kill the direct child so a timed-out
82
+ // probe still dies, matching execFile's `timeout:` behavior. No-op
83
+ // after a clean exit.
84
+ if (!GROUP_REAP)
85
+ child.kill('SIGKILL');
86
+ if (err)
87
+ reject(err);
88
+ else
89
+ resolve({ stdout: out });
90
+ };
91
+ const timer = setTimeout(() => settle(new Error(`probe timed out after ${timeoutMs}ms: ${cmd} ${args.join(' ')}`)), timeoutMs);
92
+ child.stdout?.setEncoding('utf8');
93
+ child.stdout?.on('data', (d) => {
94
+ out += d;
95
+ });
96
+ child.on('error', (e) => settle(e));
97
+ // 'exit', not 'close': a forked grandchild inherits the stdout pipe, and
98
+ // 'close' waits for EVERY holder of that pipe to exit — exactly the
99
+ // process this helper exists to reap. Settle when the probed binary
100
+ // itself exits; one tick's grace lets its final stdout chunks land.
101
+ child.on('exit', (code) => {
102
+ setImmediate(() => settle(code === 0 ? null : new Error(`probe exited ${code}: ${cmd} ${args.join(' ')}`)));
103
+ });
104
+ });
105
+ }
@@ -30,8 +30,9 @@ export declare const KNOWN_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
30
30
  * read-only local web companion + `--control` anchor) was removed with the
31
31
  * unshipped iOS Fleet Cockpit it existed for (RUSH-3001). `apply` nested under
32
32
  * `agents fleet apply` / `agents devices apply`. `beta` nested under
33
- * `agents setup beta` (RUSH-2981). `auth` and `org` (the Prix-coupled account
34
- * layer) were removed pending the Phoenix-backed replacement (RUSH-2581).
33
+ * `agents setup beta` (RUSH-2981). `org` (the Prix-coupled account layer) was
34
+ * removed; `agents auth` returned against Phoenix ID with `auth space` as the
35
+ * team surface (RUSH-2581).
35
36
  */
36
37
  export declare const RETIRED_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
37
38
  export declare function isKnownTopLevelCommand(name: string): boolean;
@@ -1,5 +1,5 @@
1
1
  const LOADED_COMMAND_NAMES = [
2
- 'accounts', 'view', 'inspect', 'feedback', 'commands', 'hooks', 'skills', 'rules', 'memory',
2
+ 'accounts', 'auth', 'view', 'inspect', 'feedback', 'commands', 'hooks', 'skills', 'rules', 'memory',
3
3
  'permissions', 'mcp', 'clis', 'subagents', 'plugins', 'workflows', 'add', 'use', 'list',
4
4
  'remove', 'rm', 'purge', 'update', 'prune', 'import', 'registry', 'search', 'install',
5
5
  'routines', 'monitors', 'projects', 'run', 'open', 'reconnect', 'fork', 'config',
@@ -50,12 +50,12 @@ export const KNOWN_TOP_LEVEL_COMMANDS = new Set([
50
50
  * read-only local web companion + `--control` anchor) was removed with the
51
51
  * unshipped iOS Fleet Cockpit it existed for (RUSH-3001). `apply` nested under
52
52
  * `agents fleet apply` / `agents devices apply`. `beta` nested under
53
- * `agents setup beta` (RUSH-2981). `auth` and `org` (the Prix-coupled account
54
- * layer) were removed pending the Phoenix-backed replacement (RUSH-2581).
53
+ * `agents setup beta` (RUSH-2981). `org` (the Prix-coupled account layer) was
54
+ * removed; `agents auth` returned against Phoenix ID with `auth space` as the
55
+ * team surface (RUSH-2581).
55
56
  */
56
57
  export const RETIRED_TOP_LEVEL_COMMANDS = new Set([
57
58
  'webhook',
58
- 'auth',
59
59
  'org',
60
60
  'serve',
61
61
  'login',
@@ -28,8 +28,8 @@ export interface ViewJsonVersion {
28
28
  resetsAt: string | null;
29
29
  }>;
30
30
  unavailable?: {
31
- reason: 'session_limit';
32
- resetsAt: string;
31
+ reason: 'session_limit' | 'out_of_credits';
32
+ resetsAt?: string;
33
33
  };
34
34
  lastActive: string | null;
35
35
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.45",
3
+ "version": "1.22.46",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",