@phnx-labs/agents-cli 1.14.1 → 1.14.2
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/README.md +19 -1
- package/dist/commands/exec.js +17 -17
- package/dist/commands/secrets.js +8 -1
- package/dist/lib/rotate.d.ts +35 -25
- package/dist/lib/rotate.js +82 -40
- package/dist/lib/types.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -260,6 +260,24 @@ agents run claude "charge a test card" --secrets prod-stripe
|
|
|
260
260
|
|
|
261
261
|
Merge order: profile env < `--secrets` < `--env K=V`. A missing keychain item aborts before the child starts.
|
|
262
262
|
|
|
263
|
+
### Cross-machine sync via iCloud Keychain
|
|
264
|
+
|
|
265
|
+
Pass `--icloud-sync` when creating a bundle and the values are written to the iCloud-synced keychain. Sign into the same iCloud account on another Mac (with iCloud Keychain enabled) and the values appear there within seconds — no copy-paste, no `.env` files emailed to yourself, no shared secret stores.
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
# On laptop:
|
|
269
|
+
agents secrets create npm-tokens --icloud-sync
|
|
270
|
+
agents secrets add npm-tokens NPM_TOKEN # value lives in iCloud Keychain
|
|
271
|
+
|
|
272
|
+
# On another Mac (same iCloud account):
|
|
273
|
+
agents secrets add npm-tokens NPM_TOKEN # the value is already there;
|
|
274
|
+
# you only need the bundle YAML locally
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Under the hood, `--icloud-sync` routes writes through a notarized helper app (`AgentsKeychain.app`) that holds the entitlement macOS requires for `kSecAttrSynchronizable`. Bundles without `--icloud-sync` use `/usr/bin/security` and stay device-local.
|
|
278
|
+
|
|
279
|
+
Bundle YAML files (`~/.agents/secrets/*.yml`) are not synced — only the secret values. Push the YAMLs across machines via `agents repo push` if you want full bundle definitions to follow.
|
|
280
|
+
|
|
263
281
|
---
|
|
264
282
|
|
|
265
283
|
## Routines
|
|
@@ -403,7 +421,7 @@ No. API keys come from your shell environment or each agent CLI's existing auth.
|
|
|
403
421
|
|
|
404
422
|
macOS and Linux. Windows via WSL works but isn't first-class yet.
|
|
405
423
|
|
|
406
|
-
**macOS-only features:** Keychain-based secrets (`agents secrets`, `agents profiles login`) require macOS. On Linux, use environment variables or `.env` files for API keys. Native Linux credential store support is planned.
|
|
424
|
+
**macOS-only features:** Keychain-based secrets (`agents secrets`, `agents profiles login`) require macOS. The `--icloud-sync` flag on bundles requires macOS + iCloud Keychain enabled. On Linux, use environment variables or `.env` files for API keys. Native Linux credential store support is planned.
|
|
407
425
|
|
|
408
426
|
### Do I need Node.js?
|
|
409
427
|
|
package/dist/commands/exec.js
CHANGED
|
@@ -16,12 +16,12 @@ const VALID_AGENTS = Object.keys(AGENT_COMMANDS);
|
|
|
16
16
|
function isValidAgent(agent) {
|
|
17
17
|
return VALID_AGENTS.includes(agent);
|
|
18
18
|
}
|
|
19
|
-
/** Build a one-line banner describing which version the
|
|
20
|
-
function formatRotationBanner(result) {
|
|
19
|
+
/** Build a one-line banner describing which version the strategy picked. */
|
|
20
|
+
function formatRotationBanner(result, verb = 'balanced') {
|
|
21
21
|
const { picked, healthy, excluded } = result;
|
|
22
22
|
const label = picked.email ? `${picked.email} · ${picked.agent}@${picked.version}` : `${picked.agent}@${picked.version}`;
|
|
23
23
|
const ratio = `${healthy.length} of ${healthy.length + excluded.length} healthy`;
|
|
24
|
-
return `[agents]
|
|
24
|
+
return `[agents] ${verb} picked ${label} (${ratio})`;
|
|
25
25
|
}
|
|
26
26
|
/** Register the `agents run <agent> [prompt]` command. */
|
|
27
27
|
export function registerRunCommand(program) {
|
|
@@ -42,8 +42,8 @@ export function registerRunCommand(program) {
|
|
|
42
42
|
.option('--verbose', 'Show detailed execution logs')
|
|
43
43
|
.option('--timeout <duration>', 'Kill the agent after this duration (e.g., 30m, 1h, 2h30m)')
|
|
44
44
|
.option('--fallback <agents>', 'Comma-separated agents to try on rate-limit failure. Each entry accepts an optional @version pin (e.g., codex@0.116.0,gemini). The primary runs first; if it exits with a rate-limit error, the next agent picks up via /continue handoff.')
|
|
45
|
-
.option('-
|
|
46
|
-
.option('--strategy <strategy>', 'Version/account selection strategy: pinned | available |
|
|
45
|
+
.option('-b, --balanced', 'Shortcut for --strategy balanced. Ignored when @version is pinned.')
|
|
46
|
+
.option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | balanced. Defaults to run.<agent>.strategy, then pinned. (Legacy `rotate` accepted as alias for `balanced`.)')
|
|
47
47
|
.option('--acp', 'Route through the Agent Client Protocol instead of direct exec. Supported for gemini, claude (via @zed-industries/claude-code-acp adapter). Unified event stream; emits ndjson when --json.')
|
|
48
48
|
.addHelpText('after', `
|
|
49
49
|
Modes:
|
|
@@ -54,18 +54,20 @@ Run strategy:
|
|
|
54
54
|
pinned Use the workspace/global pinned version from agents.yaml.
|
|
55
55
|
available Use the pinned version if it has usage available; otherwise switch
|
|
56
56
|
to another signed-in version with usage available.
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
balanced Distribute traffic across healthy accounts using weighted random
|
|
58
|
+
by remaining capacity — fresher accounts get more, near-exhausted
|
|
59
|
+
ones get less. Avoids bursting any single account.
|
|
59
60
|
Configure with run.<agent>.strategy in agents.yaml, or override with
|
|
60
|
-
--strategy. --
|
|
61
|
+
--strategy. --balanced is kept as a shortcut for --strategy balanced.
|
|
62
|
+
Legacy "rotate" is accepted as an alias for "balanced".
|
|
61
63
|
Ignored when @version is pinned, when a profile is used, or with --fallback.
|
|
62
64
|
|
|
63
65
|
Examples:
|
|
64
66
|
# Interactive with the pinned default version
|
|
65
67
|
agents run claude
|
|
66
68
|
|
|
67
|
-
# Interactive,
|
|
68
|
-
agents run claude --strategy
|
|
69
|
+
# Interactive, distribute load across healthy accounts
|
|
70
|
+
agents run claude --strategy balanced
|
|
69
71
|
|
|
70
72
|
# Headless, switch away from the pinned version when usage is unavailable
|
|
71
73
|
agents run claude "summarize recent git commits" --mode plan --strategy available
|
|
@@ -134,14 +136,14 @@ Examples:
|
|
|
134
136
|
console.error(chalk.red(`Invalid strategy: ${options.strategy}. Use ${RUN_STRATEGIES.join(', ')}.`));
|
|
135
137
|
process.exit(1);
|
|
136
138
|
}
|
|
137
|
-
if (options.
|
|
138
|
-
console.error(chalk.red('--
|
|
139
|
+
if (options.balanced && explicitStrategy && explicitStrategy !== 'balanced') {
|
|
140
|
+
console.error(chalk.red('--balanced conflicts with --strategy. Use one strategy override.'));
|
|
139
141
|
process.exit(1);
|
|
140
142
|
}
|
|
141
|
-
const strategy = options.
|
|
143
|
+
const strategy = options.balanced ? 'balanced' : explicitStrategy ?? configuredStrategy;
|
|
142
144
|
// Strategy only applies to bare agent invocations. Explicit @version,
|
|
143
145
|
// profiles, and fallback chains already define their execution target.
|
|
144
|
-
if (strategy !== 'pinned' || options.
|
|
146
|
+
if (strategy !== 'pinned' || options.balanced || explicitStrategy) {
|
|
145
147
|
if (version) {
|
|
146
148
|
process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
|
|
147
149
|
}
|
|
@@ -157,9 +159,7 @@ Examples:
|
|
|
157
159
|
if (resolved.version) {
|
|
158
160
|
version = resolved.version;
|
|
159
161
|
if (resolved.rotation) {
|
|
160
|
-
const banner = strategy
|
|
161
|
-
? formatRotationBanner(resolved.rotation).replace('rotation picked', 'available picked')
|
|
162
|
-
: formatRotationBanner(resolved.rotation);
|
|
162
|
+
const banner = formatRotationBanner(resolved.rotation, strategy);
|
|
163
163
|
process.stderr.write(chalk.gray(banner + '\n'));
|
|
164
164
|
}
|
|
165
165
|
}
|
package/dist/commands/secrets.js
CHANGED
|
@@ -139,7 +139,7 @@ function redact(value, reveal) {
|
|
|
139
139
|
export function registerSecretsCommands(program) {
|
|
140
140
|
const cmd = program
|
|
141
141
|
.command('secrets')
|
|
142
|
-
.description('Named bundles of env variables backed by macOS Keychain. Inject into agents via `agents run --secrets <name>`.')
|
|
142
|
+
.description('Named bundles of env variables backed by macOS Keychain (with optional iCloud sync). Inject into agents via `agents run --secrets <name>`.')
|
|
143
143
|
.addHelpText('after', `
|
|
144
144
|
Workflow:
|
|
145
145
|
Bundles are containers; secrets are the variables inside them. Create a
|
|
@@ -147,10 +147,17 @@ Workflow:
|
|
|
147
147
|
run with --secrets <name>. Keychain-backed values never touch disk in
|
|
148
148
|
plaintext.
|
|
149
149
|
|
|
150
|
+
Pass --icloud-sync at create time to store values in the iCloud-synced
|
|
151
|
+
keychain so they appear automatically on your other Macs (same iCloud
|
|
152
|
+
account, iCloud Keychain enabled). Without the flag, values are device-local.
|
|
153
|
+
|
|
150
154
|
Examples:
|
|
151
155
|
# Create a bundle for production credentials
|
|
152
156
|
agents secrets create prod --description "Production keys for the api stack"
|
|
153
157
|
|
|
158
|
+
# Create a bundle that syncs to your other Macs via iCloud Keychain
|
|
159
|
+
agents secrets create npm-tokens --icloud-sync
|
|
160
|
+
|
|
154
161
|
# Add a keychain-backed secret (prompts for the value)
|
|
155
162
|
agents secrets add prod STRIPE_API_KEY
|
|
156
163
|
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -25,7 +25,13 @@ export interface RotateResult {
|
|
|
25
25
|
excluded: RotateCandidate[];
|
|
26
26
|
}
|
|
27
27
|
export declare const RUN_STRATEGIES: RunStrategy[];
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Return a run strategy when the input is valid, otherwise null.
|
|
30
|
+
*
|
|
31
|
+
* `'rotate'` is accepted as a deprecated alias for `'balanced'` so old yaml
|
|
32
|
+
* configs and `--strategy rotate` invocations keep working. The legacy alias
|
|
33
|
+
* normalizes to `'balanced'` and uses the weighted-random algorithm.
|
|
34
|
+
*/
|
|
29
35
|
export declare function normalizeRunStrategy(value: unknown): RunStrategy | null;
|
|
30
36
|
/** Read project-local run strategy from the nearest agents.yaml, if present. */
|
|
31
37
|
export declare function getProjectRunStrategy(agent: AgentId, startPath: string): RunStrategy | null;
|
|
@@ -34,23 +40,29 @@ export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: str
|
|
|
34
40
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
35
41
|
export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
|
|
36
42
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
43
|
+
* Pick a healthy candidate using weighted random by remaining capacity.
|
|
44
|
+
*
|
|
45
|
+
* Each healthy candidate gets weight = max(1, 100 - usedPercent) where
|
|
46
|
+
* usedPercent is the highest-utilized non-session window (week / sonnet_week
|
|
47
|
+
* for Claude). An account at 10% used gets weight 90; one at 90% used gets
|
|
48
|
+
* weight 10 — so the fresher account is 9× more likely to be picked. Over N
|
|
49
|
+
* calls, traffic distributes across healthy accounts proportional to their
|
|
50
|
+
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
51
|
+
* callers naturally fan out via the random roll.
|
|
52
|
+
*
|
|
53
|
+
* Eligibility: signed in (email present), auth valid, and usage available
|
|
54
|
+
* (any non-session window strictly under 100%, or local flag not exhausted
|
|
55
|
+
* when no live snapshot exists).
|
|
56
|
+
*
|
|
57
|
+
* Dedupe: when multiple versions share an email, collapse to one candidate
|
|
58
|
+
* per email (the least-recently-active version). Prevents two parallel pods
|
|
59
|
+
* from "balancing" to different versions but hitting the same Anthropic
|
|
60
|
+
* account and both 429ing.
|
|
39
61
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* installed under several agent versions), collapse to one candidate per
|
|
43
|
-
* email — the least-recently-active version. Without this, two parallel
|
|
44
|
-
* pods could "rotate" to different versions but hit the same account and
|
|
45
|
-
* both 429 against the same Anthropic quota.
|
|
46
|
-
* Primary order: lowest live usage utilization wins. Least-recently-active is
|
|
47
|
-
* the tie-breaker when usage is equal or unavailable. Never-used versions sort
|
|
48
|
-
* oldest so fresh installs are tried before recently-used ones.
|
|
49
|
-
* Tie-break: random — when two candidates share a `lastActive` timestamp
|
|
50
|
-
* (common when N pods read the same snapshot), distribute across them so
|
|
51
|
-
* parallel callers fan out instead of all picking the same version.
|
|
62
|
+
* Returns null if no candidate is eligible — callers fall back to the pinned
|
|
63
|
+
* version so behavior stays predictable.
|
|
52
64
|
*/
|
|
53
|
-
export declare function
|
|
65
|
+
export declare function pickBalancedCandidate(candidates: RotateCandidate[]): RotateResult | null;
|
|
54
66
|
/**
|
|
55
67
|
* Pick an available candidate. Prefers the configured pinned version when that
|
|
56
68
|
* version has usage available; otherwise routes to the candidate with the most
|
|
@@ -58,19 +70,17 @@ export declare function pickRotateCandidate(candidates: RotateCandidate[]): Rota
|
|
|
58
70
|
*/
|
|
59
71
|
export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null): RotateResult | null;
|
|
60
72
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* utilization, with least-recently-active as a tie-breaker.
|
|
73
|
+
* Pick a healthy version for `agent` using weighted random by remaining
|
|
74
|
+
* capacity. See `pickBalancedCandidate` for algorithm details.
|
|
64
75
|
*
|
|
65
|
-
* No external state
|
|
66
|
-
* AccountInfo
|
|
67
|
-
*
|
|
76
|
+
* No external state — health and capacity are both read off per-version
|
|
77
|
+
* AccountInfo (same data `agents view` surfaces). The weighted random roll
|
|
78
|
+
* keeps parallel callers fanned out without rotation files or locks.
|
|
68
79
|
*
|
|
69
|
-
* Returns null if no installed version is eligible
|
|
70
|
-
* or every account is exhausted / not signed in). Callers fall back to the
|
|
80
|
+
* Returns null if no installed version is eligible. Callers fall back to the
|
|
71
81
|
* global default so behavior stays predictable — we never refuse to run.
|
|
72
82
|
*/
|
|
73
|
-
export declare function
|
|
83
|
+
export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
|
|
74
84
|
/** Select the configured version if available, otherwise another available version. */
|
|
75
85
|
export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
|
|
76
86
|
export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string): Promise<{
|
package/dist/lib/rotate.js
CHANGED
|
@@ -11,12 +11,20 @@ import { getAccountInfo } from './agents.js';
|
|
|
11
11
|
import { readMeta, writeMeta, getAgentsDir } from './state.js';
|
|
12
12
|
import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
|
|
13
13
|
import { getUsageInfoByIdentity, getUsageLookupKey, isClaudeAuthValid, } from './usage.js';
|
|
14
|
-
export const RUN_STRATEGIES = ['pinned', 'available', '
|
|
15
|
-
/**
|
|
14
|
+
export const RUN_STRATEGIES = ['pinned', 'available', 'balanced'];
|
|
15
|
+
/**
|
|
16
|
+
* Return a run strategy when the input is valid, otherwise null.
|
|
17
|
+
*
|
|
18
|
+
* `'rotate'` is accepted as a deprecated alias for `'balanced'` so old yaml
|
|
19
|
+
* configs and `--strategy rotate` invocations keep working. The legacy alias
|
|
20
|
+
* normalizes to `'balanced'` and uses the weighted-random algorithm.
|
|
21
|
+
*/
|
|
16
22
|
export function normalizeRunStrategy(value) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
if (typeof value !== 'string')
|
|
24
|
+
return null;
|
|
25
|
+
if (value === 'rotate')
|
|
26
|
+
return 'balanced';
|
|
27
|
+
return RUN_STRATEGIES.includes(value) ? value : null;
|
|
20
28
|
}
|
|
21
29
|
/** Read project-local run strategy from the nearest agents.yaml, if present. */
|
|
22
30
|
export function getProjectRunStrategy(agent, startPath) {
|
|
@@ -112,23 +120,29 @@ function dedupeAndSortCandidates(candidates) {
|
|
|
112
120
|
return [...byEmail.values()].sort(compareCandidates);
|
|
113
121
|
}
|
|
114
122
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
123
|
+
* Pick a healthy candidate using weighted random by remaining capacity.
|
|
124
|
+
*
|
|
125
|
+
* Each healthy candidate gets weight = max(1, 100 - usedPercent) where
|
|
126
|
+
* usedPercent is the highest-utilized non-session window (week / sonnet_week
|
|
127
|
+
* for Claude). An account at 10% used gets weight 90; one at 90% used gets
|
|
128
|
+
* weight 10 — so the fresher account is 9× more likely to be picked. Over N
|
|
129
|
+
* calls, traffic distributes across healthy accounts proportional to their
|
|
130
|
+
* headroom, with no stampede on the lowest-usage one. Stateless — parallel
|
|
131
|
+
* callers naturally fan out via the random roll.
|
|
132
|
+
*
|
|
133
|
+
* Eligibility: signed in (email present), auth valid, and usage available
|
|
134
|
+
* (any non-session window strictly under 100%, or local flag not exhausted
|
|
135
|
+
* when no live snapshot exists).
|
|
136
|
+
*
|
|
137
|
+
* Dedupe: when multiple versions share an email, collapse to one candidate
|
|
138
|
+
* per email (the least-recently-active version). Prevents two parallel pods
|
|
139
|
+
* from "balancing" to different versions but hitting the same Anthropic
|
|
140
|
+
* account and both 429ing.
|
|
117
141
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* installed under several agent versions), collapse to one candidate per
|
|
121
|
-
* email — the least-recently-active version. Without this, two parallel
|
|
122
|
-
* pods could "rotate" to different versions but hit the same account and
|
|
123
|
-
* both 429 against the same Anthropic quota.
|
|
124
|
-
* Primary order: lowest live usage utilization wins. Least-recently-active is
|
|
125
|
-
* the tie-breaker when usage is equal or unavailable. Never-used versions sort
|
|
126
|
-
* oldest so fresh installs are tried before recently-used ones.
|
|
127
|
-
* Tie-break: random — when two candidates share a `lastActive` timestamp
|
|
128
|
-
* (common when N pods read the same snapshot), distribute across them so
|
|
129
|
-
* parallel callers fan out instead of all picking the same version.
|
|
142
|
+
* Returns null if no candidate is eligible — callers fall back to the pinned
|
|
143
|
+
* version so behavior stays predictable.
|
|
130
144
|
*/
|
|
131
|
-
export function
|
|
145
|
+
export function pickBalancedCandidate(candidates) {
|
|
132
146
|
const healthy = [];
|
|
133
147
|
const excluded = [];
|
|
134
148
|
for (const c of candidates) {
|
|
@@ -146,7 +160,33 @@ export function pickRotateCandidate(candidates) {
|
|
|
146
160
|
if (!deduped.has(c))
|
|
147
161
|
excluded.push(c);
|
|
148
162
|
}
|
|
149
|
-
|
|
163
|
+
const picked = weightedRandomByCapacity(sorted);
|
|
164
|
+
return { picked, healthy: sorted, excluded };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Pick one candidate from `sorted` using weights proportional to remaining
|
|
168
|
+
* routing capacity. Floor each weight at 1 so a near-exhausted-but-still-
|
|
169
|
+
* eligible candidate can still be picked occasionally. When usage is unknown
|
|
170
|
+
* (no live snapshot), treat the candidate as full-capacity (weight 100) — we
|
|
171
|
+
* have no signal to deprioritize it.
|
|
172
|
+
*/
|
|
173
|
+
function weightedRandomByCapacity(sorted) {
|
|
174
|
+
const weights = sorted.map((c) => {
|
|
175
|
+
const used = getRoutingUsedPercent(c.usageSnapshot);
|
|
176
|
+
if (used === null)
|
|
177
|
+
return 100;
|
|
178
|
+
return Math.max(1, 100 - used);
|
|
179
|
+
});
|
|
180
|
+
const total = weights.reduce((sum, w) => sum + w, 0);
|
|
181
|
+
if (total <= 0)
|
|
182
|
+
return sorted[0];
|
|
183
|
+
let roll = Math.random() * total;
|
|
184
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
185
|
+
roll -= weights[i];
|
|
186
|
+
if (roll <= 0)
|
|
187
|
+
return sorted[i];
|
|
188
|
+
}
|
|
189
|
+
return sorted[sorted.length - 1];
|
|
150
190
|
}
|
|
151
191
|
/**
|
|
152
192
|
* Pick an available candidate. Prefers the configured pinned version when that
|
|
@@ -210,20 +250,18 @@ async function collectRunCandidates(agent) {
|
|
|
210
250
|
});
|
|
211
251
|
}
|
|
212
252
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* utilization, with least-recently-active as a tie-breaker.
|
|
253
|
+
* Pick a healthy version for `agent` using weighted random by remaining
|
|
254
|
+
* capacity. See `pickBalancedCandidate` for algorithm details.
|
|
216
255
|
*
|
|
217
|
-
* No external state
|
|
218
|
-
* AccountInfo
|
|
219
|
-
*
|
|
256
|
+
* No external state — health and capacity are both read off per-version
|
|
257
|
+
* AccountInfo (same data `agents view` surfaces). The weighted random roll
|
|
258
|
+
* keeps parallel callers fanned out without rotation files or locks.
|
|
220
259
|
*
|
|
221
|
-
* Returns null if no installed version is eligible
|
|
222
|
-
* or every account is exhausted / not signed in). Callers fall back to the
|
|
260
|
+
* Returns null if no installed version is eligible. Callers fall back to the
|
|
223
261
|
* global default so behavior stays predictable — we never refuse to run.
|
|
224
262
|
*/
|
|
225
|
-
export async function
|
|
226
|
-
return
|
|
263
|
+
export async function selectBalancedVersion(agent) {
|
|
264
|
+
return pickBalancedCandidate(await collectRunCandidates(agent));
|
|
227
265
|
}
|
|
228
266
|
/** Select the configured version if available, otherwise another available version. */
|
|
229
267
|
export async function selectAvailableVersion(agent, preferredVersion) {
|
|
@@ -268,17 +306,21 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
|
|
|
268
306
|
}
|
|
269
307
|
const rotation = strategy === 'available'
|
|
270
308
|
? await selectAvailableVersion(agent, fallback)
|
|
271
|
-
: await
|
|
309
|
+
: await selectBalancedVersion(agent);
|
|
272
310
|
if (rotation) {
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
311
|
+
// `available` is sticky to the pinned default when healthy. Use the 60s
|
|
312
|
+
// anti-collision stamp to nudge parallel callers off the same version.
|
|
313
|
+
// `balanced` doesn't need this — its weighted random roll already
|
|
314
|
+
// distributes naturally across healthy accounts.
|
|
315
|
+
if (strategy === 'available') {
|
|
316
|
+
const recentPick = readRotationStamp(agent);
|
|
317
|
+
if (recentPick === rotation.picked.version && rotation.healthy.length > 1) {
|
|
318
|
+
const alt = rotation.healthy.find(c => c.version !== recentPick);
|
|
319
|
+
if (alt)
|
|
320
|
+
rotation.picked = alt;
|
|
321
|
+
}
|
|
322
|
+
recordRotationPick(agent, rotation.picked.version);
|
|
280
323
|
}
|
|
281
|
-
recordRotationPick(agent, rotation.picked.version);
|
|
282
324
|
return { version: rotation.picked.version, rotation };
|
|
283
325
|
}
|
|
284
326
|
return { version: fallback, rotation: null };
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
/** Unique identifier for a supported AI coding agent. */
|
|
9
9
|
export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'roo';
|
|
10
10
|
/** How `agents run <agent>` chooses an installed version when none is pinned. */
|
|
11
|
-
export type RunStrategy = 'pinned' | 'available' | '
|
|
11
|
+
export type RunStrategy = 'pinned' | 'available' | 'balanced';
|
|
12
12
|
/** Preview features that users can opt into via `agents beta`. */
|
|
13
13
|
export type BetaFeatureName = 'drive' | 'factory';
|
|
14
14
|
/** Subset of chalk color names used for agent-specific terminal output. */
|
package/package.json
CHANGED