@phnx-labs/agents-cli 1.22.115 → 1.22.117

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +161 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.d.ts +18 -0
  5. package/dist/commands/exec.js +263 -152
  6. package/dist/commands/feed.js +65 -14
  7. package/dist/commands/sessions-picker.d.ts +11 -0
  8. package/dist/commands/sessions-picker.js +88 -7
  9. package/dist/commands/sessions.d.ts +21 -2
  10. package/dist/commands/sessions.js +157 -3
  11. package/dist/commands/setup-secrets.d.ts +2 -2
  12. package/dist/commands/setup-term.d.ts +25 -0
  13. package/dist/commands/setup-term.js +71 -0
  14. package/dist/commands/setup.d.ts +1 -1
  15. package/dist/commands/setup.js +12 -4
  16. package/dist/commands/ssh.js +1 -69
  17. package/dist/lib/accounting/rotate.d.ts +63 -1
  18. package/dist/lib/accounting/rotate.js +56 -0
  19. package/dist/lib/accounts/add.js +2 -2
  20. package/dist/lib/accounts/slots.js +32 -2
  21. package/dist/lib/answer-router.d.ts +11 -2
  22. package/dist/lib/answer-router.js +26 -2
  23. package/dist/lib/auth-mint.d.ts +5 -4
  24. package/dist/lib/auth-mint.js +4 -3
  25. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  26. package/dist/lib/browser/service.d.ts +10 -0
  27. package/dist/lib/browser/service.js +208 -36
  28. package/dist/lib/browser/types.d.ts +18 -0
  29. package/dist/lib/config-keys.d.ts +1 -1
  30. package/dist/lib/config-keys.js +5 -0
  31. package/dist/lib/device-config.js +61 -0
  32. package/dist/lib/devices/doctor-findings.js +2 -6
  33. package/dist/lib/feed/answer.d.ts +153 -4
  34. package/dist/lib/feed/answer.js +716 -105
  35. package/dist/lib/feed/feed.d.ts +61 -1
  36. package/dist/lib/feed/feed.js +226 -14
  37. package/dist/lib/feed/hub-server.d.ts +58 -3
  38. package/dist/lib/feed/hub-server.js +306 -54
  39. package/dist/lib/feed/pr-status.d.ts +8 -0
  40. package/dist/lib/feed/pr-status.js +9 -1
  41. package/dist/lib/feed-outcome.d.ts +1 -1
  42. package/dist/lib/feed-outcome.js +9 -2
  43. package/dist/lib/feed-policy.js +9 -3
  44. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  45. package/dist/lib/fleet/auth-sync.js +2 -89
  46. package/dist/lib/harness-auth-capabilities.js +7 -2
  47. package/dist/lib/hosts/dispatch.d.ts +20 -1
  48. package/dist/lib/hosts/dispatch.js +52 -30
  49. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  50. package/dist/lib/hosts/remote-cmd.js +27 -2
  51. package/dist/lib/mailbox.d.ts +12 -0
  52. package/dist/lib/mailbox.js +16 -2
  53. package/dist/lib/menubar/snapshot.d.ts +51 -0
  54. package/dist/lib/menubar/snapshot.js +42 -3
  55. package/dist/lib/open-url.js +2 -2
  56. package/dist/lib/placement.d.ts +2 -0
  57. package/dist/lib/placement.js +5 -0
  58. package/dist/lib/projects.d.ts +23 -0
  59. package/dist/lib/projects.js +78 -0
  60. package/dist/lib/secrets-cli.d.ts +3 -3
  61. package/dist/lib/secrets-cli.js +1 -1
  62. package/dist/lib/session/active.d.ts +1 -0
  63. package/dist/lib/session/active.js +8 -0
  64. package/dist/lib/session/db.d.ts +67 -3
  65. package/dist/lib/session/db.js +381 -126
  66. package/dist/lib/session/prompt.d.ts +23 -7
  67. package/dist/lib/session/prompt.js +46 -8
  68. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  69. package/dist/lib/session/remote/remote-list.js +22 -6
  70. package/dist/lib/session/remote/watch.d.ts +12 -0
  71. package/dist/lib/session/remote/watch.js +9 -0
  72. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  73. package/dist/lib/session/remote-preview-cache.js +373 -0
  74. package/dist/lib/session/tail.d.ts +50 -0
  75. package/dist/lib/session/tail.js +219 -0
  76. package/dist/lib/setup-tool-install.js +2 -1
  77. package/dist/lib/setup-tool-status.d.ts +1 -1
  78. package/dist/lib/setup-tool-status.js +7 -1
  79. package/dist/lib/signin-badge.d.ts +19 -4
  80. package/dist/lib/signin-badge.js +29 -11
  81. package/dist/lib/term-driver.d.ts +24 -0
  82. package/dist/lib/term-driver.js +36 -0
  83. package/dist/lib/terminal/index.d.ts +1 -1
  84. package/dist/lib/terminal/index.js +1 -1
  85. package/dist/lib/terminal/inject.d.ts +38 -0
  86. package/dist/lib/terminal/inject.js +55 -9
  87. package/dist/lib/terminal/transport.d.ts +15 -5
  88. package/dist/lib/terminal/transport.js +61 -11
  89. package/package.json +1 -1
  90. package/dist/lib/fleet/remote-login.d.ts +0 -170
  91. package/dist/lib/fleet/remote-login.js +0 -568
@@ -90,6 +90,24 @@ export function parseRunPickerMarkers(agentSpec) {
90
90
  export function hostTargetGiven(options) {
91
91
  return [options.host, options.device, options.on, options.computer].filter((v) => !!v);
92
92
  }
93
+ /**
94
+ * Turn a host target that names THIS machine into an explicit local pin: the
95
+ * host-family flags are cleared and `local` is set, so no dispatch path
96
+ * self-SSHes and the bare-interactive default (`bareInteractiveRunDefaultsToDeviceAuto`)
97
+ * sees a decided placement. Returns true when the pin was applied. Pure over
98
+ * the `isSelf` predicate so the rule is testable without a device registry.
99
+ */
100
+ export function pinLocalWhenTargetIsSelf(options, isSelf) {
101
+ const targets = hostTargetGiven(options);
102
+ if (targets.length === 0 || !targets.every(isSelf))
103
+ return false;
104
+ options.host = undefined;
105
+ options.device = undefined;
106
+ options.on = undefined;
107
+ options.computer = undefined;
108
+ options.local = true;
109
+ return true;
110
+ }
93
111
  /**
94
112
  * Return every option whose selection semantics conflict with an account
95
113
  * choice (`agent#`). Device routing is deliberately absent: the marker rides
@@ -118,6 +136,8 @@ export function runAccountPickerConflicts(options) {
118
136
  */
119
137
  export function runDevicePickerConflicts(options) {
120
138
  const conflicts = hostTargetGiven(options).map((h) => `--device ${h}`);
139
+ if (options.local)
140
+ conflicts.push('--local');
121
141
  if (options.lease)
122
142
  conflicts.push('--lease');
123
143
  if (options.box)
@@ -145,6 +165,11 @@ export { RUN_AUTO_KEYWORD };
145
165
  * fleet. Pure so the pinning matrix is unit-testable.
146
166
  */
147
167
  export function runAutoDefaultsToAffinity(options, env = process.env) {
168
+ // An explicit local pin (--local, --where local, --device <this machine>)
169
+ // is a decided host layer with an empty host set; read it here so the
170
+ // `run auto` and bare-interactive defaults share one rule.
171
+ if (options.local)
172
+ return false;
148
173
  if (hostTargetGiven(options).length > 0)
149
174
  return false;
150
175
  if (env.AGENTS_RUN_AUTO_HOST_RESOLVED === '1')
@@ -639,7 +664,8 @@ export function registerRunCommand(program) {
639
664
  .option('--until <signal>', 'Loop stop condition. `signal` reads <runDir>/loop-signal.json {continue,reason} each iteration; absent or continue:false stops (fail-closed). Loop only.')
640
665
  .option('--interval <dur>', 'Loop delay between iterations ("0" back-to-back, "30m" paces). Loop only.')
641
666
  .option('--where <spec>', 'Where this run\'s body executes (one placement door): local | device:<name> | auto | lease[:backend] | cloud[:provider]. Expands to --device/--lease/--cloud. Do not combine with those flags. See docs/00-concepts.md#placement.')
642
- .option('-D, --device <name>', 'Offload this run onto another machine over SSH — a registered device, or user@host. Pass "auto" to pick the least-loaded reachable device where the requested agent is installed and signed in, keeping the run local when no remote is better, or "interactive" for the machine pinned as interactive.host (the box a human is sitting at). Same as --where device:<name>. See `agents devices`.')
667
+ .option('--local', 'Run on this machine. A bare interactive run otherwise places itself like --device auto; --local pins it here. Same as --where local or --device <this machine>.')
668
+ .option('-D, --device <name>', 'Offload this run onto another machine over SSH — a registered device, or user@host. Pass "auto" to pick the least-loaded reachable device where the requested agent is installed and signed in, keeping the run local when no remote is better, or "interactive" for the machine pinned as interactive.host (the box a human is sitting at). Naming this machine runs locally, no SSH. Same as --where device:<name>. See `agents devices`.')
643
669
  .option('--remote-cwd <dir>', "Explicit device working directory for --device runs, used VERBATIM (overrides --cwd; usually --cwd suffices — it re-roots a local-home path onto the remote home). Pass a single-quoted '$HOME/…' or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you).")
644
670
  .option('--no-follow', 'With --device, dispatch detached and return immediately (track via `agents hosts ps/logs`).')
645
671
  .option('--any', 'With --device <cap> (a capability tag), pick any matching device instead of erroring when several match.')
@@ -680,9 +706,9 @@ export function registerRunCommand(program) {
680
706
  agents run claude "fix lint errors in src/" --mode edit
681
707
 
682
708
  # Interactive (TUI): a bare run places itself like --device auto (a fleet
683
- # worker, TUI forwarded over SSH); pin the device to stay on this machine
709
+ # worker, TUI forwarded over SSH); --local keeps it on this machine
684
710
  agents run claude
685
- agents run claude --device zion # stay local (or pick this machine in claude@)
711
+ agents run claude --local # stay local (or pick this machine in claude@)
686
712
 
687
713
  # Pick a signed-in account/version for only this run (# = account picker)
688
714
  agents run claude#
@@ -790,10 +816,10 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
790
816
  Interactive placement: a bare 'agents run <harness>' (no prompt, real TTY)
791
817
  places itself like --device auto — a fleet worker runs it, with the TUI
792
818
  forwarded over SSH. Headless runs (any prompt, --json, no TTY) are
793
- unchanged: they run in place. To stay on this machine, pass
794
- --device <this machine>, or pick this machine (listed first) in the
795
- '<harness>@' device picker. When placement finds no healthy device the
796
- run fails loud and names the local spellings.
819
+ unchanged: they run in place. To stay on this machine, pass --local
820
+ (--device <this machine> means the same), or pick this machine (listed
821
+ first) in the '<harness>@' device picker. When placement finds no
822
+ healthy device the run fails loud and names the local spelling.
797
823
 
798
824
  Fallback: --fallback codex,antigravity retries on rate-limit failure via /continue handoff. Each entry accepts @version.
799
825
 
@@ -891,6 +917,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
891
917
  const { placementFromRunFlags, expandPlacementToRunFlags, PlacementError } = await import('../lib/placement.js');
892
918
  try {
893
919
  const placement = placementFromRunFlags(options);
920
+ // An explicit local placement (--local, --where local) must outlive
921
+ // this block: expansion yields no host flag, and the bare-interactive
922
+ // default below reads an empty host set as "decide for me".
923
+ if (placement.kind === 'local' && placement.source !== 'default')
924
+ options.local = true;
894
925
  if (options.where) {
895
926
  const expanded = expandPlacementToRunFlags(placement);
896
927
  if (expanded.host !== undefined)
@@ -918,6 +949,16 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
918
949
  throw err;
919
950
  }
920
951
  }
952
+ // --device naming this machine (short id, MagicDNS name, loopback, or the
953
+ // `interactive` sentinel pinned here) is a local run, not a self-SSH: the
954
+ // hop probes its own login shell and times out under load. Pinned here,
955
+ // ahead of every placement gate, so the bare-interactive default sees an
956
+ // explicit local choice rather than an empty host set.
957
+ {
958
+ const { isSelfHost } = await import('../lib/devices/self-host.js');
959
+ const { isDeviceInteractive, resolveInteractiveDevice } = await import('../lib/devices/interactive-host.js');
960
+ pinLocalWhenTargetIsSelf(options, (name) => isSelfHost(isDeviceInteractive(name) ? resolveInteractiveDevice() ?? name : name));
961
+ }
921
962
  // Cloud refinement flags without the placement are a typo, not a no-op.
922
963
  if (!options.cloud) {
923
964
  const { cloudFlagsWithoutCloud } = await import('./run-cloud.js');
@@ -1101,13 +1142,13 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1101
1142
  const { sessionRecoveryDestinationMatches, sessionRecoveryPeer, } = await import('../lib/session/recovery.js');
1102
1143
  const sourceMachine = resolvedResumeSource.machine;
1103
1144
  const sourcePeer = sessionRecoveryPeer(resolvedResumeSource);
1104
- const explicitPlacement = hostTargetGiven(options).length > 0;
1145
+ const explicitPlacement = hostTargetGiven(options).length > 0 || options.local === true;
1105
1146
  if (sourcePeer && !explicitPlacement) {
1106
1147
  options.host = sourcePeer;
1107
1148
  }
1108
1149
  else if (sourcePeer && explicitPlacement && !hostTargetGiven(options).some((host) => sessionRecoveryDestinationMatches(resolvedResumeSource, host))) {
1109
1150
  console.error(chalk.red(`Session ${resolvedResumeSource.shortId} must recover on ${sourcePeer}, where its conversation state is stored; ` +
1110
- `the requested device was ${hostTargetGiven(options).join(', ')}.`));
1151
+ `the requested device was ${hostTargetGiven(options).join(', ') || 'this machine'}.`));
1111
1152
  process.exit(1);
1112
1153
  }
1113
1154
  // Recovery is resolved on the device that owns the transcript. A remote
@@ -1246,8 +1287,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1246
1287
  if (!defaultPlacement)
1247
1288
  throw err;
1248
1289
  console.error(chalk.red(err.message));
1249
- const { machineId } = await import('../lib/machine-id.js');
1250
- console.error(chalk.gray(`Run here instead: agents run ${runBaseAgentName} --device ${machineId()}`));
1290
+ console.error(chalk.gray(`Run here instead: agents run ${runBaseAgentName} --local`));
1251
1291
  process.exit(1);
1252
1292
  }
1253
1293
  if (!options.quiet && result.banner) {
@@ -2045,7 +2085,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2045
2085
  const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, implicitModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, readProfile, resolveProfileForRun },
2046
2086
  // Engine DATA ops resolve through the standalone `secrets` process client
2047
2087
  // (PHNX-3989); the agents-owned `bundle@host` policy helpers stay in agents-cli.
2048
- { readAndResolveBundleEnv, describeBundle, remoteResolveEnv }, { assertRemoteBundleFlagsUnsupported, splitBundleRef, resolveSecretsContextForRun }, { resolveHostSshTarget }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError, formatNoVerifiedUsageError, signInRecoverableCandidates }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
2088
+ { readAndResolveBundleEnv, describeBundle, remoteResolveEnv }, { assertRemoteBundleFlagsUnsupported, splitBundleRef, resolveSecretsContextForRun }, { resolveHostSshTarget }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, DEFAULT_ROTATION_FAILOVER_LIMIT, shouldArmRotationFailover, preflightFallbackHandoff, preflightHandoffEligible, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError, formatNoVerifiedUsageError, signInRecoverableCandidates }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
2049
2089
  import('../lib/exec.js'),
2050
2090
  import('../lib/agents.js'),
2051
2091
  import('../lib/profiles.js'),
@@ -2590,6 +2630,21 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2590
2630
  // synthesize a same-agent fallback chain from the other healthy accounts
2591
2631
  // (issue #348). Stays null unless a non-pinned strategy actually rotated.
2592
2632
  let rotationResult = null;
2633
+ /**
2634
+ * The PRIMARY harness's configured run-default mode/effort, carried across a
2635
+ * preflight alternate-harness handoff (PHNX-3999 F19).
2636
+ *
2637
+ * Run defaults are resolved after the harness is known, so without this a
2638
+ * handoff would silently adopt the ALTERNATE's configured defaults — a
2639
+ * primary configured `mode: plan` becoming the alternate's `mode: skip`
2640
+ * escalates a read-only intent into an unattended writable run. The
2641
+ * canonical mid-run cascade keeps the primary's resolved mode for the same
2642
+ * reason (`runWithFallback` forwards `options.mode` and only re-derives the
2643
+ * mode that was never configured at all). An IMPLICIT mode is deliberately
2644
+ * not carried: it is the harness's own safe default, so the alternate's
2645
+ * applies (identical to `modeWasImplicit ? implicitModeFor(agent)`).
2646
+ */
2647
+ let handoffRunDefaults;
2593
2648
  // Precomputed launchable-signed-in verdict for the ACTUAL launched
2594
2649
  // candidate, fed to the pre-launch `run.launch` event so it need not
2595
2650
  // re-probe. Sourced per resolution branch from the candidate that WON, not
@@ -2622,155 +2677,202 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2622
2677
  // account and account rotation silently stops (the gh-monitor heal bug).
2623
2678
  // `pinned` still resolves so a logged-out default can yield to a signed-in
2624
2679
  // sibling on this device (PHNX-2685); an explicit @version pin is unchanged.
2625
- if (!accountPickerRequested && !configuredAccount && (!version || strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2626
- if (version) {
2627
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2628
- }
2629
- else if (fromProfile) {
2630
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: custom harness pins its own version/auth\n`));
2631
- }
2632
- else {
2633
- try {
2634
- // Account-centric candidate list: native version-home logins PLUS
2635
- // provider accounts (setup-token / API-key) that can auth this agent,
2636
- // so `--strategy balanced` spreads across ALL accounts, not just the
2637
- // ones sitting in a version home (RUSH-3182). Run-path only — the
2638
- // picker's other callers keep the native-only collector.
2639
- const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2640
- bootMark('resolve-version:start');
2641
- const routingModel = options.model ?? resolvedResumeSource?.model ?? workflowModel
2642
- ?? (options.fallback ? undefined : resolveRunDefaults(agent, resolveVersion(agent, cwd), cwd).model);
2643
- const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun, routingModel);
2644
- bootMark('resolve-version:done');
2645
- if (resolved.exhausted) {
2646
- // Zero healthy accounts splits two ways, and conflating them is what
2647
- // stranded a logged-out harness with no way in at all (RUSH-2334):
2648
- //
2649
- // - THROTTLED (rate_limited / out_of_credits) -> fail loud (RUSH-2132).
2650
- // The old behavior warned "found no usable version; falling back to
2651
- // defaults" and launched the pinned default anyway — the exact move
2652
- // that loops a rotate into an exhausted account. Only a window reset
2653
- // clears it. The message text is a contract the Factory watchdog
2654
- // tail-detects; do not reword it.
2655
- // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2656
- // because the harness's own TUI is the login surface. So on a TTY we
2657
- // carry the user into that login instead of erroring. Exiting here
2658
- // made `agents run <agent>`, `agents run <agent>#`, and `agents use`
2659
- // all dead-end with no reachable way to authenticate.
2660
- const recoverable = signInRecoverableCandidates(resolved.exhausted);
2661
- const { signInLaunchDecision } = await import('./run-account-picker.js');
2662
- const decision = signInLaunchDecision({
2663
- recoverable: recoverable.length,
2664
- tty: isInteractiveTerminal(),
2665
- json: options.json === true,
2666
- });
2667
- if (decision === 'launch') {
2668
- const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2669
- const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2670
- // A cancelled prompt launches nothing — same contract as the
2671
- // trailing-# account picker above.
2672
- if (!signInVersion)
2673
- return;
2674
- version = signInVersion;
2675
- // We just told the user this account is logged out and why we're
2676
- // launching it, so suppress the downstream login preflight — it
2677
- // would print a second, near-identical "looks logged out" warning.
2678
- signInLaunch = true;
2680
+ // Bounded handoff loop: each iteration resolves an account for `agent`, and
2681
+ // the only `continue` is the preflight exhaustion handoff below, which
2682
+ // consumes one entry of the `--fallback` spec before retrying as that
2683
+ // alternate. The spec is finite, so this terminates (PHNX-3999 F19).
2684
+ preflight: for (;;) {
2685
+ if (!accountPickerRequested && !configuredAccount && (!version || strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2686
+ if (version) {
2687
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2688
+ }
2689
+ else if (fromProfile) {
2690
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: custom harness pins its own version/auth\n`));
2691
+ }
2692
+ else {
2693
+ try {
2694
+ // Account-centric candidate list: native version-home logins PLUS
2695
+ // provider accounts (setup-token / API-key) that can auth this agent,
2696
+ // so `--strategy balanced` spreads across ALL accounts, not just the
2697
+ // ones sitting in a version home (RUSH-3182). Run-path only — the
2698
+ // picker's other callers keep the native-only collector.
2699
+ const { collectRunCandidatesForRun } = await import('../lib/accounting/account-pool-collect.js');
2700
+ bootMark('resolve-version:start');
2701
+ const routingModel = options.model ?? resolvedResumeSource?.model ?? workflowModel
2702
+ ?? (options.fallback ? undefined : resolveRunDefaults(agent, resolveVersion(agent, cwd), cwd).model);
2703
+ const resolved = await resolveRunVersion(agent, strategy, cwd, collectRunCandidatesForRun, routingModel);
2704
+ bootMark('resolve-version:done');
2705
+ if (resolved.exhausted) {
2706
+ // Zero healthy accounts splits two ways, and conflating them is what
2707
+ // stranded a logged-out harness with no way in at all (RUSH-2334):
2708
+ //
2709
+ // - THROTTLED (rate_limited / out_of_credits) -> fail loud (RUSH-2132).
2710
+ // The old behavior warned "found no usable version; falling back to
2711
+ // defaults" and launched the pinned default anyway — the exact move
2712
+ // that loops a rotate into an exhausted account. Only a window reset
2713
+ // clears it. The message text is a contract the Factory watchdog
2714
+ // tail-detects; do not reword it.
2715
+ // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2716
+ // because the harness's own TUI is the login surface. So on a TTY we
2717
+ // carry the user into that login instead of erroring. Exiting here
2718
+ // made `agents run <agent>`, `agents run <agent>#`, and `agents use`
2719
+ // all dead-end with no reachable way to authenticate.
2720
+ const recoverable = signInRecoverableCandidates(resolved.exhausted);
2721
+ const { signInLaunchDecision } = await import('./run-account-picker.js');
2722
+ const decision = signInLaunchDecision({
2723
+ recoverable: recoverable.length,
2724
+ tty: isInteractiveTerminal(),
2725
+ json: options.json === true,
2726
+ });
2727
+ if (decision === 'launch') {
2728
+ const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2729
+ const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2730
+ // A cancelled prompt launches nothing — same contract as the
2731
+ // trailing-# account picker above.
2732
+ if (!signInVersion)
2733
+ return;
2734
+ version = signInVersion;
2735
+ // We just told the user this account is logged out and why we're
2736
+ // launching it, so suppress the downstream login preflight — it
2737
+ // would print a second, near-identical "looks logged out" warning.
2738
+ signInLaunch = true;
2739
+ }
2740
+ else {
2741
+ // Every account of this harness is throttled. A configured
2742
+ // alternate exists for exactly this case, so hand off to it here
2743
+ // rather than exiting — `runWithFallback` would only reach it
2744
+ // after a run that can no longer start (PHNX-3999 F19). The
2745
+ // alternate becomes the primary and is dropped from the spec, so
2746
+ // the chain that remains never lists the agent now running.
2747
+ //
2748
+ // ONLY for a run shape `--fallback` is valid on. The handoff
2749
+ // consumes the entry it uses, and the canonical --fallback
2750
+ // validation runs later (it rejects interactive/--acp/--loop/
2751
+ // --resume-checkpoint chains and requires a prompt) — so
2752
+ // switching first on an ineligible shape would empty the spec,
2753
+ // leave that guard nothing to reject, and start the alternate
2754
+ // harness on a run the CLI refuses today. An ineligible shape
2755
+ // never switches, so the spec survives and still fails there
2756
+ // with its own message. See preflightHandoffEligible.
2757
+ const handoff = preflightHandoffEligible({
2758
+ hasPrompt: prompt !== undefined,
2759
+ interactive: options.interactive === true,
2760
+ acp: options.acp === true,
2761
+ loop: options.loop === true,
2762
+ resumeCheckpoint: !!options.resumeCheckpoint,
2763
+ resume: !!options.resume,
2764
+ workflowScoped: !!workflowToolsRestrict || !!workflowMcpConfigPath,
2765
+ })
2766
+ ? preflightFallbackHandoff(options.fallback, agent, resolved.exhausted)
2767
+ : null;
2768
+ if (handoff) {
2769
+ process.stderr.write(chalk.yellow(`[agents] every ${agent} account is exhausted — handing off to ${handoff.agent}${handoff.version ? `@${handoff.version}` : ''}\n`));
2770
+ // Snapshot the PRIMARY's configured defaults BEFORE the switch —
2771
+ // this is the operator's intent for this run, not the alternate's.
2772
+ handoffRunDefaults ??= fromProfile ? undefined : resolveRunDefaults(agent, version ?? resolveVersion(agent, cwd), cwd);
2773
+ agent = handoff.agent;
2774
+ // Alias-resolve against the NEW harness's installs, exactly as
2775
+ // the canonical --fallback parse does for its own entries.
2776
+ version = resolveVersionAlias(agent, handoff.version);
2777
+ options.fallback = handoff.remainingSpec;
2778
+ rotationResult = null;
2779
+ continue preflight;
2780
+ }
2781
+ console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2782
+ if (recoverable.length > 0) {
2783
+ // Off a TTY nobody can complete a login, so we still exit — but
2784
+ // name the actual fix rather than only offering --strategy pinned,
2785
+ // which would just pin the same unauthenticated account.
2786
+ const { loginHint } = await import('../lib/signin-badge.js');
2787
+ console.error(chalk.gray(`To sign in: ${loginHint(agent)} — or run \`agents run ${agent}\` from a terminal.`));
2788
+ }
2789
+ process.exit(1);
2790
+ }
2679
2791
  }
2680
- else {
2681
- console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2682
- if (recoverable.length > 0) {
2683
- // Off a TTY nobody can complete a login, so we still exit — but
2684
- // name the actual fix rather than only offering --strategy pinned,
2685
- // which would just pin the same unauthenticated account.
2686
- const { loginHint } = await import('../lib/signin-badge.js');
2687
- console.error(chalk.gray(`To sign in: ${loginHint(agent)} — or run \`agents run ${agent}\` from a terminal.`));
2792
+ else if (resolved.noVerifiedUsage) {
2793
+ // Entirely stale usage (PHNX-2526): every eligible account carries
2794
+ // a stale-but-present usage number and none is verified, so the
2795
+ // route would be a guess. NEVER auto-pick it. Interactive: show the
2796
+ // account picker so a human chooses with the (stale) numbers in
2797
+ // view. Unattended: fail loud with NO_VERIFIED_USAGE. The stale
2798
+ // pool survives ONLY as `resolved.rotation.healthy` for bounded
2799
+ // post-rejection failover, never as the initial pick.
2800
+ const { noVerifiedUsageDecision, pickRunAccountCandidate } = await import('./run-account-picker.js');
2801
+ const decision = noVerifiedUsageDecision({
2802
+ tty: isInteractiveTerminal(),
2803
+ json: options.json === true,
2804
+ headless: options.headless === true,
2805
+ });
2806
+ if (decision === 'picker') {
2807
+ const selected = await pickRunAccountCandidate(agent);
2808
+ // A cancelled picker launches nothing — same contract as the
2809
+ // trailing-# account picker and the sign-in launch above.
2810
+ if (!selected)
2811
+ return;
2812
+ version = selected.version;
2813
+ // Source the run.launch verdict from the account the user ACTUALLY
2814
+ // picked — the picker may deliberately return a logged-out one
2815
+ // (RUSH-2334), so it can differ from rotationResult.picked.
2816
+ launchSignedIn = selected.signedIn;
2817
+ launchEmail = selected.email;
2818
+ // Keep the rotation so mid-run failover can still cascade across
2819
+ // the other (stale) healthy accounts after a real rejection.
2820
+ rotationResult = resolved.rotation;
2821
+ if (!options.quiet) {
2822
+ const identity = selected.accountLabel || 'signed-in account';
2823
+ process.stderr.write(chalk.gray(`[agents] no fresh usage for any ${agent} account — you picked ${identity} · ${agent}@${selected.version}\n`));
2824
+ }
2825
+ }
2826
+ else {
2827
+ console.error(chalk.red(formatNoVerifiedUsageError(agent, strategy, resolved.rotation?.healthy ?? [])));
2828
+ process.exit(1);
2688
2829
  }
2689
- process.exit(1);
2690
2830
  }
2691
- }
2692
- else if (resolved.noVerifiedUsage) {
2693
- // Entirely stale usage (PHNX-2526): every eligible account carries
2694
- // a stale-but-present usage number and none is verified, so the
2695
- // route would be a guess. NEVER auto-pick it. Interactive: show the
2696
- // account picker so a human chooses with the (stale) numbers in
2697
- // view. Unattended: fail loud with NO_VERIFIED_USAGE. The stale
2698
- // pool survives ONLY as `resolved.rotation.healthy` for bounded
2699
- // post-rejection failover, never as the initial pick.
2700
- const { noVerifiedUsageDecision, pickRunAccountCandidate } = await import('./run-account-picker.js');
2701
- const decision = noVerifiedUsageDecision({
2702
- tty: isInteractiveTerminal(),
2703
- json: options.json === true,
2704
- headless: options.headless === true,
2705
- });
2706
- if (decision === 'picker') {
2707
- const selected = await pickRunAccountCandidate(agent);
2708
- // A cancelled picker launches nothing — same contract as the
2709
- // trailing-# account picker and the sign-in launch above.
2710
- if (!selected)
2711
- return;
2712
- version = selected.version;
2713
- // Source the run.launch verdict from the account the user ACTUALLY
2714
- // picked — the picker may deliberately return a logged-out one
2715
- // (RUSH-2334), so it can differ from rotationResult.picked.
2716
- launchSignedIn = selected.signedIn;
2717
- launchEmail = selected.email;
2718
- // Keep the rotation so mid-run failover can still cascade across
2719
- // the other (stale) healthy accounts after a real rejection.
2831
+ else if (resolved.version) {
2832
+ version = resolved.version;
2720
2833
  rotationResult = resolved.rotation;
2721
- if (!options.quiet) {
2722
- const identity = selected.accountLabel || 'signed-in account';
2723
- process.stderr.write(chalk.gray(`[agents] no fresh usage for any ${agent} account — you picked ${identity} · ${agent}@${selected.version}\n`));
2834
+ // The auto-pick already computed the launchable-signed-in verdict for
2835
+ // this exact version via the same gate — reuse it for run.launch.
2836
+ if (resolved.rotation) {
2837
+ launchSignedIn = resolved.rotation.picked.signedIn;
2838
+ launchEmail = resolved.rotation.picked.email;
2724
2839
  }
2725
- }
2726
- else {
2727
- console.error(chalk.red(formatNoVerifiedUsageError(agent, strategy, resolved.rotation?.healthy ?? [])));
2728
- process.exit(1);
2729
- }
2730
- }
2731
- else if (resolved.version) {
2732
- version = resolved.version;
2733
- rotationResult = resolved.rotation;
2734
- // The auto-pick already computed the launchable-signed-in verdict for
2735
- // this exact version via the same gate — reuse it for run.launch.
2736
- if (resolved.rotation) {
2737
- launchSignedIn = resolved.rotation.picked.signedIn;
2738
- launchEmail = resolved.rotation.picked.email;
2739
- }
2740
- // A balanced/available pick of a PROVIDER account (setup-token /
2741
- // API-key) carries `providerAccount`. Resolve its env through the
2742
- // same `resolveSpawnAccount` path an explicit `--account` uses, so
2743
- // exec injects the credential; a native pick has no providerAccount
2744
- // and runs from its own version home unchanged (RUSH-3182).
2745
- const pickedProviderAccount = resolved.rotation?.picked.providerAccount;
2746
- if (pickedProviderAccount) {
2747
- try {
2748
- const picked = resolveSpawnAccount(pickedProviderAccount, agent, resolved.version, readMeta(), { useDefault: false });
2749
- if (picked?.kind === 'provider')
2750
- accountEnv = picked.env;
2840
+ // A balanced/available pick of a PROVIDER account (setup-token /
2841
+ // API-key) carries `providerAccount`. Resolve its env through the
2842
+ // same `resolveSpawnAccount` path an explicit `--account` uses, so
2843
+ // exec injects the credential; a native pick has no providerAccount
2844
+ // and runs from its own version home unchanged (RUSH-3182).
2845
+ const pickedProviderAccount = resolved.rotation?.picked.providerAccount;
2846
+ if (pickedProviderAccount) {
2847
+ try {
2848
+ const picked = resolveSpawnAccount(pickedProviderAccount, agent, resolved.version, readMeta(), { useDefault: false });
2849
+ if (picked?.kind === 'provider')
2850
+ accountEnv = picked.env;
2851
+ }
2852
+ catch (err) {
2853
+ console.error(chalk.red(err.message));
2854
+ process.exit(1);
2855
+ }
2751
2856
  }
2752
- catch (err) {
2753
- console.error(chalk.red(err.message));
2754
- process.exit(1);
2857
+ if (resolved.rotation && !options.quiet) {
2858
+ const banner = formatRotationBanner(resolved.rotation, strategy);
2859
+ process.stderr.write(chalk.gray(banner + '\n'));
2755
2860
  }
2756
2861
  }
2757
- if (resolved.rotation && !options.quiet) {
2758
- const banner = formatRotationBanner(resolved.rotation, strategy);
2759
- process.stderr.write(chalk.gray(banner + '\n'));
2862
+ else if (!options.quiet) {
2863
+ // No installed version at all (not "accounts exhausted" — that
2864
+ // fails loud above): keep the pre-existing default resolution.
2865
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} found no usable ${agent} version; falling back to defaults\n`));
2760
2866
  }
2761
2867
  }
2762
- else if (!options.quiet) {
2763
- // No installed version at all (not "accounts exhausted" — that
2764
- // fails loud above): keep the pre-existing default resolution.
2765
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} found no usable ${agent} version; falling back to defaults\n`));
2766
- }
2767
- }
2768
- catch (err) {
2769
- if (!options.quiet) {
2770
- process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} skipped: ${err.message}\n`));
2868
+ catch (err) {
2869
+ if (!options.quiet) {
2870
+ process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} skipped: ${err.message}\n`));
2871
+ }
2771
2872
  }
2772
2873
  }
2773
2874
  }
2875
+ break;
2774
2876
  }
2775
2877
  // Self-heal the launch target. A gutted install (JS wrapper present,
2776
2878
  // native binary missing — a partial/raced npm extraction of the optional
@@ -2899,9 +3001,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2899
3001
  // alias for 'skip' (rewritten downstream by normalizeMode in exec.ts).
2900
3002
  let mode = options.mode;
2901
3003
  const modeSource = runCmd.getOptionValueSource('mode');
2902
- const modeFromRunDefault = modeSource === 'default' && !!runDefaults.mode;
3004
+ // A configured mode carried across a preflight handoff wins over the
3005
+ // alternate harness's own configured default (see handoffRunDefaults).
3006
+ const configuredMode = handoffRunDefaults?.mode ?? runDefaults.mode;
3007
+ const modeFromRunDefault = modeSource === 'default' && !!configuredMode;
2903
3008
  if (modeFromRunDefault) {
2904
- mode = runDefaults.mode;
3009
+ mode = configuredMode;
2905
3010
  }
2906
3011
  if (!['plan', 'edit', 'auto', 'skip', 'full'].includes(mode)) {
2907
3012
  console.error(chalk.red(`Invalid mode: ${mode}. Use plan, edit, auto, or skip ('full' accepted as alias for skip).`));
@@ -2952,7 +3057,8 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2952
3057
  process.exit(1);
2953
3058
  }
2954
3059
  const effortSource = runCmd.getOptionValueSource('effort');
2955
- const effort = (effortSource === 'default' && runDefaults.effort ? runDefaults.effort : options.effort);
3060
+ const configuredEffort = handoffRunDefaults?.effort ?? runDefaults.effort;
3061
+ const effort = (effortSource === 'default' && configuredEffort ? configuredEffort : options.effort);
2956
3062
  if (!['low', 'medium', 'high', 'xhigh', 'max', 'auto'].includes(effort)) {
2957
3063
  console.error(chalk.red(`Invalid effort: ${effort}. Use 'low', 'medium', 'high', 'xhigh', 'max', or 'auto'`));
2958
3064
  process.exit(1);
@@ -3190,7 +3296,12 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
3190
3296
  loop: !!options.loop,
3191
3297
  resumeCheckpoint: !!options.resumeCheckpoint,
3192
3298
  })) {
3193
- const failover = rotationFailoverChain(rotationResult, version);
3299
+ // With an explicit alternate harness configured, "use the alternate once
3300
+ // the primary's accounts are exhausted" is the policy — so EVERY healthy
3301
+ // same-agent account has to be tried first, not the default cap of three
3302
+ // (PHNX-3999 F19). Without one, the cap stands: it bounds how long a
3303
+ // rate-limited run keeps retrying itself.
3304
+ const failover = rotationFailoverChain(rotationResult, version, fallback.length > 0 ? (rotationResult.healthy.length || DEFAULT_ROTATION_FAILOVER_LIMIT) : undefined);
3194
3305
  if (failover.length > 0) {
3195
3306
  fallback.unshift(...failover);
3196
3307
  if (!options.quiet) {