@phnx-labs/agents-cli 1.22.49 → 1.22.50
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 +102 -0
- package/README.md +10 -0
- package/dist/commands/accounts.js +1 -13
- package/dist/commands/daemon-test-harness.d.ts +42 -0
- package/dist/commands/daemon-test-harness.js +90 -0
- package/dist/commands/device-config-test-harness.d.ts +11 -0
- package/dist/commands/device-config-test-harness.js +59 -0
- package/dist/commands/exec.js +24 -1
- package/dist/commands/share.js +9 -4
- package/dist/commands/teams.d.ts +12 -0
- package/dist/commands/teams.js +44 -67
- package/dist/commands/traces.js +4 -0
- package/dist/lib/account-provider-registry.d.ts +9 -0
- package/dist/lib/account-provider-registry.js +20 -0
- package/dist/lib/accounting/account-pool-collect.d.ts +34 -0
- package/dist/lib/accounting/account-pool-collect.js +71 -0
- package/dist/lib/accounting/account-pool.d.ts +46 -0
- package/dist/lib/accounting/account-pool.js +33 -0
- package/dist/lib/accounting/capacity.d.ts +24 -0
- package/dist/lib/accounting/capacity.js +30 -0
- package/dist/lib/accounting/rotate.d.ts +11 -18
- package/dist/lib/accounting/rotate.js +6 -24
- package/dist/lib/installations/store.js +6 -3
- package/dist/lib/installations/versions.js +48 -46
- package/dist/lib/share/publish.d.ts +8 -3
- package/dist/lib/share/publish.js +6 -3
- package/dist/lib/share/worker-template.js +239 -22
- package/dist/lib/teams/agents.d.ts +14 -12
- package/dist/lib/teams/agents.js +188 -73
- package/dist/lib/teams/api.d.ts +6 -1
- package/dist/lib/teams/api.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/teams/scheduler.js +26 -8
- package/dist/lib/traces/sync.js +12 -0
- package/package.json +1 -1
package/dist/commands/traces.js
CHANGED
|
@@ -32,6 +32,10 @@ const SYNC_NOTES = `
|
|
|
32
32
|
An incremental gate (traces-sync.json ledger) means re-runs only upload
|
|
33
33
|
sessions whose file_mtime_ms changed since the last sync.
|
|
34
34
|
|
|
35
|
+
On a real (non-dry-run) sync with the managed Phoenix backend, also
|
|
36
|
+
fire-and-forget registers this session with Prix (api.prix.dev) so the
|
|
37
|
+
console can serve live trajectories — a link failure never blocks sync.
|
|
38
|
+
|
|
35
39
|
Sign in first with: agents auth login
|
|
36
40
|
`.trimStart();
|
|
37
41
|
const STATUS_EXAMPLES = `
|
|
@@ -15,4 +15,13 @@ export interface AccountProviderAdapter {
|
|
|
15
15
|
validate(kind: AccountAuthKind, value: string): void;
|
|
16
16
|
}
|
|
17
17
|
export declare function getAccountProvider(provider: string): AccountProviderAdapter;
|
|
18
|
+
/**
|
|
19
|
+
* Whether a provider account of kind `auth` can authenticate `agent` — i.e. the
|
|
20
|
+
* adapter has an `envFor(agent, auth)` mapping. Used both by the account UI and by
|
|
21
|
+
* the run-candidate pool so a Cursor key never enters Claude's pool and a harness
|
|
22
|
+
* with no provider adapter at all (e.g. kimi, native-login only) yields nothing.
|
|
23
|
+
* A "cannot authenticate" is a clean false; any other error (unknown provider,
|
|
24
|
+
* unsupported kind) is a real bug and re-throws rather than being swallowed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function providerAuthenticatesHarness(provider: string, auth: AccountAuthKind, agent: AgentId): boolean;
|
|
18
27
|
export declare function listAccountProviders(): string[];
|
|
@@ -54,6 +54,26 @@ export function getAccountProvider(provider) {
|
|
|
54
54
|
throw new Error(`Unknown account provider '${provider}'. Supported: ${listAccountProviders().join(', ')}.`);
|
|
55
55
|
return adapter;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether a provider account of kind `auth` can authenticate `agent` — i.e. the
|
|
59
|
+
* adapter has an `envFor(agent, auth)` mapping. Used both by the account UI and by
|
|
60
|
+
* the run-candidate pool so a Cursor key never enters Claude's pool and a harness
|
|
61
|
+
* with no provider adapter at all (e.g. kimi, native-login only) yields nothing.
|
|
62
|
+
* A "cannot authenticate" is a clean false; any other error (unknown provider,
|
|
63
|
+
* unsupported kind) is a real bug and re-throws rather than being swallowed.
|
|
64
|
+
*/
|
|
65
|
+
export function providerAuthenticatesHarness(provider, auth, agent) {
|
|
66
|
+
try {
|
|
67
|
+
getAccountProvider(provider).envFor(agent, auth);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
72
|
+
if (message.includes('cannot authenticate'))
|
|
73
|
+
return false;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
57
77
|
export function listAccountProviders() {
|
|
58
78
|
return [...ADAPTERS.keys()].sort();
|
|
59
79
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AgentId } from '../types.js';
|
|
2
|
+
import { type RotateCandidate } from './rotate.js';
|
|
3
|
+
import { type RegistryAccountRecord } from './account-pool.js';
|
|
4
|
+
/** Inputs to {@link foldRegistryCandidates} — injectable so the fold is unit-tested. */
|
|
5
|
+
export interface RunCandidateInputs {
|
|
6
|
+
native: RotateCandidate[];
|
|
7
|
+
records: RegistryAccountRecord[];
|
|
8
|
+
runVersion: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* PURE: fold provider-account candidates into the native version-home list.
|
|
12
|
+
*
|
|
13
|
+
* A registry account runs in `runVersion` (the agent's default installed home);
|
|
14
|
+
* the token/key overrides that home's own login at exec, so any home works. It
|
|
15
|
+
* carries `providerAccount` so the run path routes it through the existing
|
|
16
|
+
* `--account` injection (`resolveSpawnAccount` → `accountEnv`). An account already
|
|
17
|
+
* covered by a native login is skipped by accountKey. With no installed version
|
|
18
|
+
* there is nowhere to execute a registry account, so the native list passes
|
|
19
|
+
* through unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export declare function foldRegistryCandidates(agent: AgentId, inputs: RunCandidateInputs): RotateCandidate[];
|
|
22
|
+
/**
|
|
23
|
+
* Candidate list for the `agents run` strategy pick, account-centric: the usual
|
|
24
|
+
* native version-home candidates PLUS one candidate per provider account whose
|
|
25
|
+
* provider can authenticate `agent` and whose credential is present locally
|
|
26
|
+
* (RUSH-3182). This is what makes a setup-token / API-key account balance-eligible.
|
|
27
|
+
*
|
|
28
|
+
* Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path
|
|
29
|
+
* ONLY — the other consumers of {@link collectRunCandidates} (watchdog, session
|
|
30
|
+
* recovery, teams placement) keep calling it directly and are unaffected. The
|
|
31
|
+
* existing {@link pickBalancedCandidate} selects over the combined list, so the
|
|
32
|
+
* revoked / rate-limit / exhausted logic is unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export declare function collectRunCandidatesForRun(agent: AgentId): Promise<RotateCandidate[]>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readAccountRegistry } from '../account-registry.js';
|
|
2
|
+
import { hasKeychainToken } from '../secrets/index.js';
|
|
3
|
+
import { getGlobalDefault, listInstalledVersions } from '../installations/versions.js';
|
|
4
|
+
import { collectRunCandidates } from './rotate.js';
|
|
5
|
+
import { registryPoolCandidates } from './account-pool.js';
|
|
6
|
+
/**
|
|
7
|
+
* Provider accounts whose credential is present on THIS device, as capability
|
|
8
|
+
* records. A registry entry with no local secret is skipped: including it would
|
|
9
|
+
* let `--strategy balanced` pick an account that then fails at spawn and exits the
|
|
10
|
+
* whole run (`resolveSpawnAccount` throws on a missing credential) instead of
|
|
11
|
+
* rotating to a healthy one.
|
|
12
|
+
*/
|
|
13
|
+
function localRegistryRecords() {
|
|
14
|
+
return Object.values(readAccountRegistry().accounts)
|
|
15
|
+
.filter((a) => hasKeychainToken(a.secretRef))
|
|
16
|
+
.map((a) => ({ name: a.name, provider: a.provider, auth: a.auth }));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* PURE: fold provider-account candidates into the native version-home list.
|
|
20
|
+
*
|
|
21
|
+
* A registry account runs in `runVersion` (the agent's default installed home);
|
|
22
|
+
* the token/key overrides that home's own login at exec, so any home works. It
|
|
23
|
+
* carries `providerAccount` so the run path routes it through the existing
|
|
24
|
+
* `--account` injection (`resolveSpawnAccount` → `accountEnv`). An account already
|
|
25
|
+
* covered by a native login is skipped by accountKey. With no installed version
|
|
26
|
+
* there is nowhere to execute a registry account, so the native list passes
|
|
27
|
+
* through unchanged.
|
|
28
|
+
*/
|
|
29
|
+
export function foldRegistryCandidates(agent, inputs) {
|
|
30
|
+
const { native, records, runVersion } = inputs;
|
|
31
|
+
if (!runVersion)
|
|
32
|
+
return native;
|
|
33
|
+
const seen = new Set(native.filter((c) => c.accountKey).map((c) => c.accountKey));
|
|
34
|
+
const extra = registryPoolCandidates(records, agent)
|
|
35
|
+
.filter((r) => !seen.has(r.accountKey))
|
|
36
|
+
.map((r) => ({
|
|
37
|
+
agent,
|
|
38
|
+
version: runVersion,
|
|
39
|
+
accountKey: r.accountKey,
|
|
40
|
+
accountLabel: r.name,
|
|
41
|
+
email: null,
|
|
42
|
+
usageKey: null,
|
|
43
|
+
usageStatus: null,
|
|
44
|
+
usageSnapshot: null,
|
|
45
|
+
usageError: null,
|
|
46
|
+
usageMinutesToLimit: null,
|
|
47
|
+
plan: null,
|
|
48
|
+
signedIn: true,
|
|
49
|
+
authVerdict: null,
|
|
50
|
+
lastActive: null,
|
|
51
|
+
providerAccount: r.name,
|
|
52
|
+
}));
|
|
53
|
+
return [...native, ...extra];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Candidate list for the `agents run` strategy pick, account-centric: the usual
|
|
57
|
+
* native version-home candidates PLUS one candidate per provider account whose
|
|
58
|
+
* provider can authenticate `agent` and whose credential is present locally
|
|
59
|
+
* (RUSH-3182). This is what makes a setup-token / API-key account balance-eligible.
|
|
60
|
+
*
|
|
61
|
+
* Passed as the `collect` argument to {@link resolveRunVersion} on the RUN path
|
|
62
|
+
* ONLY — the other consumers of {@link collectRunCandidates} (watchdog, session
|
|
63
|
+
* recovery, teams placement) keep calling it directly and are unaffected. The
|
|
64
|
+
* existing {@link pickBalancedCandidate} selects over the combined list, so the
|
|
65
|
+
* revoked / rate-limit / exhausted logic is unchanged.
|
|
66
|
+
*/
|
|
67
|
+
export async function collectRunCandidatesForRun(agent) {
|
|
68
|
+
const native = await collectRunCandidates(agent);
|
|
69
|
+
const runVersion = getGlobalDefault(agent) ?? listInstalledVersions(agent)[0];
|
|
70
|
+
return foldRegistryCandidates(agent, { native, records: localRegistryRecords(), runVersion });
|
|
71
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AgentId } from '../types.js';
|
|
2
|
+
import type { AccountAuthKind } from '../account-provider-registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* The provider-account side of the run-candidate pool (RUSH-3182).
|
|
5
|
+
*
|
|
6
|
+
* `--strategy balanced` used to enumerate only version-home native logins, so a
|
|
7
|
+
* setup-token / API-key account added via `agents accounts add` never balanced.
|
|
8
|
+
* This module turns the account registry into the extra candidates the run path
|
|
9
|
+
* folds in — see `collectRunCandidatesForRun` in `account-pool-collect.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Pure + dependency-light so the harness-capability filter is unit-tested in
|
|
12
|
+
* isolation with fixtures.
|
|
13
|
+
*/
|
|
14
|
+
/** A provider account record as stored in the registry (identity captured separately). */
|
|
15
|
+
export interface RegistryAccountRecord {
|
|
16
|
+
name: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
auth: AccountAuthKind;
|
|
19
|
+
}
|
|
20
|
+
/** A registry account eligible to run one harness, ready to map to a candidate. */
|
|
21
|
+
export interface RegistryAccountInput {
|
|
22
|
+
/** Agent-scoped key so `(claude, X)` and `(codex, X)` stay distinct. */
|
|
23
|
+
accountKey: string;
|
|
24
|
+
email: string | null;
|
|
25
|
+
name: string;
|
|
26
|
+
provider: string;
|
|
27
|
+
auth: AccountAuthKind;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Which provider accounts can authenticate `agent`. An account is a candidate for
|
|
31
|
+
* harness H only when its provider adapter has an `envFor(H, kind)` mapping
|
|
32
|
+
* (`providerAuthenticatesHarness`, the same check the account UI uses) — so a
|
|
33
|
+
* Cursor key never enters Claude's pool and a Claude setup-token never enters
|
|
34
|
+
* Codex's. This is registry-driven, no per-harness `else if`: a harness gets a
|
|
35
|
+
* provider-account pool exactly when the adapter table maps a provider to it —
|
|
36
|
+
* today claude, codex, grok, cursor, opencode (plus gemini/antigravity). A harness
|
|
37
|
+
* with only a native OAuth login and no provider adapter — kimi — returns `[]`
|
|
38
|
+
* here and keeps balancing its native logins only.
|
|
39
|
+
*
|
|
40
|
+
* `accountKey` is synthetic here (`${agent}:name=${name}`) — a stable per-account
|
|
41
|
+
* key so balancing can include it immediately; the real agent-scoped identity
|
|
42
|
+
* (email / org uuid) is backfilled by identity capture and replaces it. `email`
|
|
43
|
+
* is null until then, which routes the account as usage-unverified but still a
|
|
44
|
+
* candidate, never excluded.
|
|
45
|
+
*/
|
|
46
|
+
export declare function registryPoolCandidates(records: RegistryAccountRecord[], agent: AgentId): RegistryAccountInput[];
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { providerAuthenticatesHarness } from '../account-provider-registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which provider accounts can authenticate `agent`. An account is a candidate for
|
|
4
|
+
* harness H only when its provider adapter has an `envFor(H, kind)` mapping
|
|
5
|
+
* (`providerAuthenticatesHarness`, the same check the account UI uses) — so a
|
|
6
|
+
* Cursor key never enters Claude's pool and a Claude setup-token never enters
|
|
7
|
+
* Codex's. This is registry-driven, no per-harness `else if`: a harness gets a
|
|
8
|
+
* provider-account pool exactly when the adapter table maps a provider to it —
|
|
9
|
+
* today claude, codex, grok, cursor, opencode (plus gemini/antigravity). A harness
|
|
10
|
+
* with only a native OAuth login and no provider adapter — kimi — returns `[]`
|
|
11
|
+
* here and keeps balancing its native logins only.
|
|
12
|
+
*
|
|
13
|
+
* `accountKey` is synthetic here (`${agent}:name=${name}`) — a stable per-account
|
|
14
|
+
* key so balancing can include it immediately; the real agent-scoped identity
|
|
15
|
+
* (email / org uuid) is backfilled by identity capture and replaces it. `email`
|
|
16
|
+
* is null until then, which routes the account as usage-unverified but still a
|
|
17
|
+
* candidate, never excluded.
|
|
18
|
+
*/
|
|
19
|
+
export function registryPoolCandidates(records, agent) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const r of records) {
|
|
22
|
+
if (!providerAuthenticatesHarness(r.provider, r.auth, agent))
|
|
23
|
+
continue;
|
|
24
|
+
out.push({
|
|
25
|
+
accountKey: `${agent}:name=${r.name}`,
|
|
26
|
+
email: null,
|
|
27
|
+
name: r.name,
|
|
28
|
+
provider: r.provider,
|
|
29
|
+
auth: r.auth,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remaining-capacity weighting for account selection — a pure, dependency-free
|
|
3
|
+
* primitive shared by the version-home router (`rotate.ts`) and the account pool
|
|
4
|
+
* (`account-pool.ts`). Kept in its own module so either can import it without
|
|
5
|
+
* dragging in the heavy usage/secrets graph.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* How far from its projected cap an account must be to keep its FULL headroom
|
|
9
|
+
* weight. Inside this horizon the weight is scaled down linearly toward the
|
|
10
|
+
* floor, so an account racing toward its 5h cap loses priority before it maxes.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PROJECTION_HORIZON_MIN = 30;
|
|
13
|
+
/**
|
|
14
|
+
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
15
|
+
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
16
|
+
* an account with no live snapshot is treated as full-capacity (100) since there
|
|
17
|
+
* is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
|
|
18
|
+
* projection on the 5h session window) then scales that base: >= horizon (or
|
|
19
|
+
* unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
|
|
20
|
+
* so a launch avoids an account projected to cap soon, not just a 100%-maxed
|
|
21
|
+
* one. Pure + exported so the deprioritization is unit-tested directly (a
|
|
22
|
+
* weighted-random draw is not).
|
|
23
|
+
*/
|
|
24
|
+
export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remaining-capacity weighting for account selection — a pure, dependency-free
|
|
3
|
+
* primitive shared by the version-home router (`rotate.ts`) and the account pool
|
|
4
|
+
* (`account-pool.ts`). Kept in its own module so either can import it without
|
|
5
|
+
* dragging in the heavy usage/secrets graph.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* How far from its projected cap an account must be to keep its FULL headroom
|
|
9
|
+
* weight. Inside this horizon the weight is scaled down linearly toward the
|
|
10
|
+
* floor, so an account racing toward its 5h cap loses priority before it maxes.
|
|
11
|
+
*/
|
|
12
|
+
export const PROJECTION_HORIZON_MIN = 30;
|
|
13
|
+
/**
|
|
14
|
+
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
15
|
+
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
16
|
+
* an account with no live snapshot is treated as full-capacity (100) since there
|
|
17
|
+
* is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
|
|
18
|
+
* projection on the 5h session window) then scales that base: >= horizon (or
|
|
19
|
+
* unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
|
|
20
|
+
* so a launch avoids an account projected to cap soon, not just a 100%-maxed
|
|
21
|
+
* one. Pure + exported so the deprioritization is unit-tested directly (a
|
|
22
|
+
* weighted-random draw is not).
|
|
23
|
+
*/
|
|
24
|
+
export function capacityWeight(usedPercent, minutesToLimit) {
|
|
25
|
+
const base = usedPercent === null ? 100 : Math.max(1, 100 - usedPercent);
|
|
26
|
+
if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
|
|
27
|
+
return base;
|
|
28
|
+
const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
|
|
29
|
+
return Math.max(1, base * factor);
|
|
30
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentId, RunStrategy } from '../types.js';
|
|
8
8
|
import type { FallbackEntry } from '../exec.js';
|
|
9
|
+
import { PROJECTION_HORIZON_MIN, capacityWeight } from './capacity.js';
|
|
9
10
|
import { type AccountInfo, type CredentialPresence } from '../agents.js';
|
|
10
11
|
import { type UsageSnapshot } from './usage.js';
|
|
11
12
|
import { type AuthVerdict } from '../auth-health.js';
|
|
@@ -49,6 +50,15 @@ export interface RotateCandidate {
|
|
|
49
50
|
*/
|
|
50
51
|
authVerdict: AuthVerdict | null;
|
|
51
52
|
lastActive: Date | null;
|
|
53
|
+
/**
|
|
54
|
+
* Set only for a candidate that comes from a provider account bundle rather
|
|
55
|
+
* than a native version-home login (RUSH-3182): the account name to inject via
|
|
56
|
+
* the `--account` spawn path (`resolveSpawnAccount` → `accountEnv`). Undefined
|
|
57
|
+
* for a native login, whose credential already lives in `version`'s home. The
|
|
58
|
+
* run path routes on this so a balanced pick of a setup-token / API-key account
|
|
59
|
+
* authenticates through the existing provider-account injection.
|
|
60
|
+
*/
|
|
61
|
+
providerAccount?: string;
|
|
52
62
|
}
|
|
53
63
|
export interface RotateResult {
|
|
54
64
|
/** The version picked for this run. */
|
|
@@ -212,24 +222,7 @@ export declare function checkRunAccountReadiness(agent: AgentId, version: string
|
|
|
212
222
|
* version so behavior stays predictable.
|
|
213
223
|
*/
|
|
214
224
|
export declare function pickBalancedCandidate(candidates: RotateCandidate[], nowMs?: number): RotateResult | null;
|
|
215
|
-
|
|
216
|
-
* How far from its projected cap an account must be to keep its FULL headroom
|
|
217
|
-
* weight. Inside this horizon the weight is scaled down linearly toward the
|
|
218
|
-
* floor, so an account racing toward its 5h cap loses priority before it maxes.
|
|
219
|
-
*/
|
|
220
|
-
export declare const PROJECTION_HORIZON_MIN = 30;
|
|
221
|
-
/**
|
|
222
|
-
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
223
|
-
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
224
|
-
* an account with no live snapshot is treated as full-capacity (100) since there
|
|
225
|
-
* is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
|
|
226
|
-
* projection on the 5h session window) then scales that base: >= horizon (or
|
|
227
|
-
* unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
|
|
228
|
-
* so a launch avoids an account projected to cap soon, not just a 100%-maxed
|
|
229
|
-
* one. Pure + exported so the deprioritization is unit-tested directly (a
|
|
230
|
-
* weighted-random draw is not).
|
|
231
|
-
*/
|
|
232
|
-
export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
|
|
225
|
+
export { PROJECTION_HORIZON_MIN, capacityWeight };
|
|
233
226
|
/**
|
|
234
227
|
* Pick an available candidate. Prefers the configured pinned version when that
|
|
235
228
|
* version has usage available; otherwise routes to the candidate with the most
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
+
import { PROJECTION_HORIZON_MIN, capacityWeight } from './capacity.js';
|
|
9
10
|
import { accountDisplayLabel, getAccountInfo, credentialPresence, ALL_AGENT_IDS, } from '../agents.js';
|
|
10
11
|
import { readMeta, writeMeta, getHelpersDir } from '../state.js';
|
|
11
12
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from '../installations/versions.js';
|
|
@@ -365,30 +366,11 @@ function preferVerified(pool, nowMs, choose, narrowing = 'any-verified') {
|
|
|
365
366
|
usageUnverified: !isUsageVerified(picked, nowMs),
|
|
366
367
|
};
|
|
367
368
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
export const PROJECTION_HORIZON_MIN = 30;
|
|
374
|
-
/**
|
|
375
|
-
* Weight one candidate by remaining routing capacity, deprioritized by how soon
|
|
376
|
-
* it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
|
|
377
|
-
* an account with no live snapshot is treated as full-capacity (100) since there
|
|
378
|
-
* is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
|
|
379
|
-
* projection on the 5h session window) then scales that base: >= horizon (or
|
|
380
|
-
* unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
|
|
381
|
-
* so a launch avoids an account projected to cap soon, not just a 100%-maxed
|
|
382
|
-
* one. Pure + exported so the deprioritization is unit-tested directly (a
|
|
383
|
-
* weighted-random draw is not).
|
|
384
|
-
*/
|
|
385
|
-
export function capacityWeight(usedPercent, minutesToLimit) {
|
|
386
|
-
const base = usedPercent === null ? 100 : Math.max(1, 100 - usedPercent);
|
|
387
|
-
if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
|
|
388
|
-
return base;
|
|
389
|
-
const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
|
|
390
|
-
return Math.max(1, base * factor);
|
|
391
|
-
}
|
|
369
|
+
// capacityWeight + PROJECTION_HORIZON_MIN moved to ./capacity.js (a pure,
|
|
370
|
+
// dependency-free module the account pool can import without this file's heavy
|
|
371
|
+
// graph). Imported at the top for internal use; re-exported here so existing
|
|
372
|
+
// importers keep resolving them from rotate.
|
|
373
|
+
export { PROJECTION_HORIZON_MIN, capacityWeight };
|
|
392
374
|
/**
|
|
393
375
|
* Pick one candidate from `sorted` using weights proportional to remaining
|
|
394
376
|
* routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
|
|
@@ -269,10 +269,13 @@ export function getBinaryPath(agent, version) {
|
|
|
269
269
|
const agentConfig = AGENTS[agent];
|
|
270
270
|
if (agent === 'grok') {
|
|
271
271
|
const grokDownloads = path.join(getVersionHomePath(agent, version), '.grok', 'downloads');
|
|
272
|
-
//
|
|
272
|
+
// The directory token is the stable installation/account label. A
|
|
273
|
+
// self-updating slot may carry a newer vendor release, whose binary keeps
|
|
274
|
+
// the release in its filename; resolve through the frozen install record.
|
|
275
|
+
const releaseVersion = readInstallation(agent, version)?.releaseVersion ?? version;
|
|
273
276
|
try {
|
|
274
277
|
const entries = fs.readdirSync(grokDownloads);
|
|
275
|
-
const match = entries.find((e) => e.includes(
|
|
278
|
+
const match = entries.find((e) => e.includes(releaseVersion) && e.startsWith('grok-'));
|
|
276
279
|
if (match)
|
|
277
280
|
return path.join(grokDownloads, match);
|
|
278
281
|
}
|
|
@@ -280,7 +283,7 @@ export function getBinaryPath(agent, version) {
|
|
|
280
283
|
const fallback = resolveGrokFallbackBinary(grokDownloads);
|
|
281
284
|
if (fallback)
|
|
282
285
|
return fallback;
|
|
283
|
-
return path.join(grokDownloads, `grok-${
|
|
286
|
+
return path.join(grokDownloads, `grok-${releaseVersion}`);
|
|
284
287
|
}
|
|
285
288
|
if (agent === 'droid') {
|
|
286
289
|
// Factory.ai's installer drops a standalone native binary (no npm package,
|
|
@@ -34,7 +34,7 @@ import { discoverPermissionGroups, getActivePermissionPresetName, readPermission
|
|
|
34
34
|
import { parseMcpConfigForScan, isProjectMcpTrusted } from '../mcp.js';
|
|
35
35
|
import { createVersionedAlias, removeVersionedAlias, switchConfigSymlink, getConfigSymlinkVersion, ensureClaudeInsideSymlink, assertIsolationBoundary, isIsolationProtected, } from './shims.js';
|
|
36
36
|
import { importInstallScriptBinary } from '../import.js';
|
|
37
|
-
import { createInstallation, getBinaryPath, getCliVersionFromPath, getGlobalDefault, getIsolatedDefault, getLiveVersion, getVersionDir, getVersionHomePath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, isGlobalBinaryAgent, isVersionInstalled, isVersionIsolated, listInstalledVersions, pickCanonicalGlobalBinaryVersion, resolveVersion, } from './store.js';
|
|
37
|
+
import { createInstallation, getBinaryPath, getCliVersionFromPath, getGlobalDefault, getIsolatedDefault, getLiveVersion, getVersionDir, getVersionHomePath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, isGlobalBinaryAgent, isVersionInstalled, isVersionIsolated, listInstalledVersions, pickCanonicalGlobalBinaryVersion, resolveGrokFallbackBinary, resolveVersion, } from './store.js';
|
|
38
38
|
export * from './store.js';
|
|
39
39
|
import { INSTALLATION_RECORD_FILE } from './types.js';
|
|
40
40
|
import { composeWin32CommandLine } from '../platform/index.js';
|
|
@@ -1032,9 +1032,9 @@ export function setIsolatedDefault(agent, version) {
|
|
|
1032
1032
|
/**
|
|
1033
1033
|
* Move any `grok-<installedVersion>-...` binary (and its generic platform
|
|
1034
1034
|
* copy) found directly under `sourceDownloads` into `targetDownloads`.
|
|
1035
|
-
* Returns true if the versioned binary was
|
|
1035
|
+
* Returns true if the versioned binary was transferred.
|
|
1036
1036
|
*/
|
|
1037
|
-
function
|
|
1037
|
+
function transferGrokDownloads(sourceDownloads, targetDownloads, copy) {
|
|
1038
1038
|
if (!fs.existsSync(sourceDownloads))
|
|
1039
1039
|
return false;
|
|
1040
1040
|
if (path.resolve(sourceDownloads) === path.resolve(targetDownloads))
|
|
@@ -1042,42 +1042,36 @@ function tryMoveGrokDownloads(sourceDownloads, targetDownloads, installedVersion
|
|
|
1042
1042
|
const entries = fs.readdirSync(sourceDownloads).filter((e) => e.startsWith('grok-'));
|
|
1043
1043
|
if (entries.length === 0)
|
|
1044
1044
|
return false;
|
|
1045
|
-
const
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
if (fs.existsSync(dst))
|
|
1056
|
-
continue;
|
|
1057
|
-
try {
|
|
1058
|
-
fs.mkdirSync(targetDownloads, { recursive: true });
|
|
1059
|
-
fs.renameSync(src, dst);
|
|
1060
|
-
movedPaths.push(dst);
|
|
1061
|
-
}
|
|
1062
|
-
catch {
|
|
1063
|
-
/* ignore per-file failures */
|
|
1064
|
-
}
|
|
1045
|
+
const realBinary = resolveGrokFallbackBinary(sourceDownloads);
|
|
1046
|
+
if (!realBinary)
|
|
1047
|
+
return false;
|
|
1048
|
+
const transferred = path.join(targetDownloads, path.basename(realBinary));
|
|
1049
|
+
try {
|
|
1050
|
+
fs.mkdirSync(targetDownloads, { recursive: true });
|
|
1051
|
+
if (copy)
|
|
1052
|
+
fs.copyFileSync(realBinary, transferred);
|
|
1053
|
+
else
|
|
1054
|
+
fs.renameSync(realBinary, transferred);
|
|
1065
1055
|
}
|
|
1066
|
-
|
|
1056
|
+
catch {
|
|
1067
1057
|
return false;
|
|
1058
|
+
}
|
|
1068
1059
|
// The installer also creates a generic platform binary (e.g. grok-macos-aarch64)
|
|
1069
1060
|
// that is a copy of the versioned binary. Move it too if its size matches.
|
|
1070
|
-
const movedSize = fs.statSync(
|
|
1061
|
+
const movedSize = fs.statSync(transferred).size;
|
|
1071
1062
|
for (const entry of entries) {
|
|
1072
|
-
if (
|
|
1073
|
-
continue;
|
|
1063
|
+
if (entry === path.basename(realBinary))
|
|
1064
|
+
continue;
|
|
1074
1065
|
const src = path.join(sourceDownloads, entry);
|
|
1075
1066
|
const dst = path.join(targetDownloads, entry);
|
|
1076
1067
|
if (fs.existsSync(dst))
|
|
1077
1068
|
continue;
|
|
1078
1069
|
try {
|
|
1079
1070
|
if (fs.statSync(src).size === movedSize) {
|
|
1080
|
-
|
|
1071
|
+
if (copy)
|
|
1072
|
+
fs.copyFileSync(src, dst);
|
|
1073
|
+
else
|
|
1074
|
+
fs.renameSync(src, dst);
|
|
1081
1075
|
}
|
|
1082
1076
|
}
|
|
1083
1077
|
catch {
|
|
@@ -1099,7 +1093,7 @@ function tryMoveGrokDownloads(sourceDownloads, targetDownloads, installedVersion
|
|
|
1099
1093
|
* whatever version was default at the time), sweep every other installed
|
|
1100
1094
|
* grok version home for the missing file before giving up.
|
|
1101
1095
|
*/
|
|
1102
|
-
function relocateGrokBinaryToVersionHome(
|
|
1096
|
+
function relocateGrokBinaryToVersionHome(installationLabel, copyExisting) {
|
|
1103
1097
|
const hostGrokLink = path.join(getHomeDir(), agentConfigDirName('grok'));
|
|
1104
1098
|
let sourceDownloads;
|
|
1105
1099
|
try {
|
|
@@ -1108,16 +1102,16 @@ function relocateGrokBinaryToVersionHome(installedVersion) {
|
|
|
1108
1102
|
catch {
|
|
1109
1103
|
sourceDownloads = path.join(hostGrokLink, 'downloads');
|
|
1110
1104
|
}
|
|
1111
|
-
const targetDownloads = path.join(getVersionHomePath('grok',
|
|
1112
|
-
if (
|
|
1105
|
+
const targetDownloads = path.join(getVersionHomePath('grok', installationLabel), agentConfigDirName('grok'), 'downloads');
|
|
1106
|
+
if (transferGrokDownloads(sourceDownloads, targetDownloads, copyExisting))
|
|
1113
1107
|
return;
|
|
1114
1108
|
for (const version of listInstalledVersions('grok')) {
|
|
1115
|
-
if (version ===
|
|
1109
|
+
if (version === installationLabel)
|
|
1116
1110
|
continue;
|
|
1117
1111
|
const candidate = path.join(getVersionHomePath('grok', version), agentConfigDirName('grok'), 'downloads');
|
|
1118
1112
|
if (path.resolve(candidate) === path.resolve(sourceDownloads))
|
|
1119
1113
|
continue; // already tried
|
|
1120
|
-
if (
|
|
1114
|
+
if (transferGrokDownloads(candidate, targetDownloads, copyExisting))
|
|
1121
1115
|
return;
|
|
1122
1116
|
}
|
|
1123
1117
|
}
|
|
@@ -1158,6 +1152,7 @@ async function checkGrokAccountCollision(installedVersion) {
|
|
|
1158
1152
|
*/
|
|
1159
1153
|
export async function installVersion(agent, version, onProgress, opts) {
|
|
1160
1154
|
const agentConfig = AGENTS[agent];
|
|
1155
|
+
const requestedLabel = version;
|
|
1161
1156
|
if (isAgentHardDeprecated(agent)) {
|
|
1162
1157
|
return { success: false, installedVersion: version, error: hardDeprecationError(agent) };
|
|
1163
1158
|
}
|
|
@@ -1189,7 +1184,7 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1189
1184
|
}
|
|
1190
1185
|
version = 'latest';
|
|
1191
1186
|
}
|
|
1192
|
-
let
|
|
1187
|
+
let releaseVersion = version;
|
|
1193
1188
|
try {
|
|
1194
1189
|
if (runInstaller) {
|
|
1195
1190
|
const script = agentConfig.installScript.replaceAll('VERSION', version);
|
|
@@ -1218,13 +1213,19 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1218
1213
|
error: `${agentConfig.name} installed but its version could not be determined after several attempts.`,
|
|
1219
1214
|
};
|
|
1220
1215
|
}
|
|
1221
|
-
|
|
1216
|
+
releaseVersion = probed;
|
|
1222
1217
|
// Fold any stale literal `latest` dir from an earlier probe-failed
|
|
1223
1218
|
// install into the real version so it stops shadowing `agents view`.
|
|
1224
|
-
await reconcileStaleLatestDir(agent,
|
|
1219
|
+
await reconcileStaleLatestDir(agent, releaseVersion);
|
|
1225
1220
|
}
|
|
1221
|
+
// Self-updating installers always fetch the current vendor release, but a
|
|
1222
|
+
// concrete requested token is still the stable installation/account slot.
|
|
1223
|
+
// Keeping the two strings separate lets several homes carry the same
|
|
1224
|
+
// release without sharing credentials. `latest` remains the convenient
|
|
1225
|
+
// release-named slot; concrete tokens remain exactly addressable.
|
|
1226
|
+
const installationLabel = requestedLabel === 'latest' ? releaseVersion : requestedLabel;
|
|
1226
1227
|
if (agent === 'grok') {
|
|
1227
|
-
await checkGrokAccountCollision(
|
|
1228
|
+
await checkGrokAccountCollision(installationLabel);
|
|
1228
1229
|
}
|
|
1229
1230
|
onProgress?.(`${agentConfig.name} installed. Setting up agents-cli version home for isolation...`);
|
|
1230
1231
|
}
|
|
@@ -1233,14 +1234,15 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1233
1234
|
return { success: false, installedVersion: version, error: `${agentConfig.name} installer failed: ${err.message}` };
|
|
1234
1235
|
}
|
|
1235
1236
|
ensureAgentsDir();
|
|
1236
|
-
const
|
|
1237
|
+
const installationLabel = requestedLabel === 'latest' ? releaseVersion : requestedLabel;
|
|
1238
|
+
const versionDir = getVersionDir(agent, installationLabel);
|
|
1237
1239
|
fs.mkdirSync(versionDir, { recursive: true });
|
|
1238
1240
|
fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
|
|
1239
1241
|
// Grok's installer drops the binary into ~/.grok/downloads, which currently
|
|
1240
1242
|
// resolves to the PREVIOUS default home. Move it into the target version home
|
|
1241
1243
|
// so version isolation is correct.
|
|
1242
1244
|
if (agent === 'grok') {
|
|
1243
|
-
relocateGrokBinaryToVersionHome(
|
|
1245
|
+
relocateGrokBinaryToVersionHome(installationLabel, !runInstaller);
|
|
1244
1246
|
}
|
|
1245
1247
|
// Symlink the installed binary into the version's node_modules/.bin so
|
|
1246
1248
|
// listInstalledVersions (which checks getBinaryPath) sees this version as
|
|
@@ -1265,26 +1267,26 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1265
1267
|
// that exec-loops forever).
|
|
1266
1268
|
const installedBinary = findInPath(agentConfig.cliCommand);
|
|
1267
1269
|
if (installedBinary) {
|
|
1268
|
-
importInstallScriptBinary({ agentId: agent, npmPackage: agentConfig.npmPackage, cliCommand: agentConfig.cliCommand },
|
|
1270
|
+
importInstallScriptBinary({ agentId: agent, npmPackage: agentConfig.npmPackage, cliCommand: agentConfig.cliCommand }, installationLabel, installedBinary, versionDir);
|
|
1269
1271
|
}
|
|
1270
1272
|
/* If null: binary missing from PATH (install script failed silently) or
|
|
1271
1273
|
only our shim is present. Leave the version dir empty so getBinaryPath
|
|
1272
1274
|
correctly reports it uninstalled. */
|
|
1273
1275
|
}
|
|
1274
|
-
createVersionedAlias(agent,
|
|
1276
|
+
createVersionedAlias(agent, installationLabel);
|
|
1275
1277
|
// Freeze this installation's identity. The dir name is its stable label from
|
|
1276
1278
|
// here on; the release it carries is recorded separately so `agents update`
|
|
1277
1279
|
// can move the release without invalidating any reference to the label.
|
|
1278
|
-
createInstallation(agent,
|
|
1279
|
-
const trackerInstall = await installSessionTrackerHook(agent,
|
|
1280
|
+
createInstallation(agent, installationLabel, releaseVersion);
|
|
1281
|
+
const trackerInstall = await installSessionTrackerHook(agent, installationLabel);
|
|
1280
1282
|
if (!trackerInstall.installed && trackerInstall.error) {
|
|
1281
|
-
console.warn(`agents: SessionStart hook not installed for ${agent}@${
|
|
1283
|
+
console.warn(`agents: SessionStart hook not installed for ${agent}@${installationLabel}: ${trackerInstall.error}`);
|
|
1282
1284
|
}
|
|
1283
1285
|
// The self-updating binary just changed on disk — drop the cached
|
|
1284
1286
|
// `--version` so `agents view` reflects the freshly-installed release.
|
|
1285
1287
|
invalidateLiveVersionCache(agent);
|
|
1286
|
-
emit('version.install', { agent, version:
|
|
1287
|
-
return { success: true, installedVersion };
|
|
1288
|
+
emit('version.install', { agent, version: installationLabel });
|
|
1289
|
+
return { success: true, installedVersion: installationLabel };
|
|
1288
1290
|
}
|
|
1289
1291
|
// Resolve the `latest`/`oldest` aliases to a concrete npm version up front so
|
|
1290
1292
|
// the rest of the install path treats them as an ordinary pinned install.
|