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