@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 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
 
@@ -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 rotation picked. */
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] rotation picked ${label} (${ratio})`;
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('-r, --rotate', 'Shortcut for --strategy rotate. Ignored when @version is pinned.')
46
- .option('--strategy <strategy>', 'Version/account selection strategy: pinned | available | rotate. Defaults to run.<agent>.strategy, then pinned.')
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
- rotate Pick the signed-in account with usage available and the most
58
- headroom; last-active breaks ties.
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. --rotate is kept as a shortcut for --strategy rotate.
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, rotate to the least-used healthy account
68
- agents run claude --strategy rotate
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.rotate && explicitStrategy && explicitStrategy !== 'rotate') {
138
- console.error(chalk.red('--rotate conflicts with --strategy. Use one strategy override.'));
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.rotate ? 'rotate' : explicitStrategy ?? configuredStrategy;
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.rotate || explicitStrategy) {
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 === 'available'
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
  }
@@ -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
 
@@ -25,7 +25,13 @@ export interface RotateResult {
25
25
  excluded: RotateCandidate[];
26
26
  }
27
27
  export declare const RUN_STRATEGIES: RunStrategy[];
28
- /** Return a run strategy when the input is valid, otherwise null. */
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
- * Pure selection: given a set of candidates, return the best one for the
38
- * next run. Kept separate from I/O so it can be unit-tested with fixtures.
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
- * Eligibility: signed in (email present) and not out of credits.
41
- * Dedupe: when multiple versions share an email (same Anthropic account
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 pickRotateCandidate(candidates: RotateCandidate[]): RotateResult | null;
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
- * Rotate across installed versions of an agent and pick the best one for the
62
- * next run. "Best" means: signed in, usage available, and lowest usage
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: rotation and health are both read off per-version
66
- * AccountInfo — the same data `agents view` already surfaces. `lastActive`
67
- * advances naturally after each run, so the cursor is self-maintaining.
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 (either nothing installed
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 selectRotateVersion(agent: AgentId): Promise<RotateResult | null>;
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<{
@@ -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', 'rotate'];
15
- /** Return a run strategy when the input is valid, otherwise null. */
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
- return typeof value === 'string' && RUN_STRATEGIES.includes(value)
18
- ? value
19
- : null;
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
- * Pure selection: given a set of candidates, return the best one for the
116
- * next run. Kept separate from I/O so it can be unit-tested with fixtures.
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
- * Eligibility: signed in (email present) and not out of credits.
119
- * Dedupe: when multiple versions share an email (same Anthropic account
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 pickRotateCandidate(candidates) {
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
- return { picked: sorted[0], healthy: sorted, excluded };
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
- * Rotate across installed versions of an agent and pick the best one for the
214
- * next run. "Best" means: signed in, usage available, and lowest usage
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: rotation and health are both read off per-version
218
- * AccountInfo — the same data `agents view` already surfaces. `lastActive`
219
- * advances naturally after each run, so the cursor is self-maintaining.
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 (either nothing installed
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 selectRotateVersion(agent) {
226
- return pickRotateCandidate(await collectRunCandidates(agent));
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 selectRotateVersion(agent);
309
+ : await selectBalancedVersion(agent);
272
310
  if (rotation) {
273
- // If another process just picked the same version (within 60s), try the
274
- // next healthy candidate to distribute load across accounts.
275
- const recentPick = readRotationStamp(agent);
276
- if (recentPick === rotation.picked.version && rotation.healthy.length > 1) {
277
- const alt = rotation.healthy.find(c => c.version !== recentPick);
278
- if (alt)
279
- rotation.picked = alt;
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 };
@@ -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' | 'rotate';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.14.1",
3
+ "version": "1.14.2",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",